@yourrealm/bottom-sheet 0.0.0-stage → 0.2.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 +40 -0
- package/LICENSE +21 -0
- package/README.md +354 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/rolldown-runtime-DODCFbG6.js +13 -0
- package/dist/sheet-5lG9EBB5.d.ts +136 -0
- package/dist/sheet-C1fUpy71.js +1448 -0
- package/dist/styled.css +168 -0
- package/dist/styled.d.ts +106 -0
- package/dist/styled.js +125 -0
- package/dist/styles.css +105 -0
- package/package.json +63 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-11-02)
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- The styled parts from `@yourrealm/bottom-sheet/styled` are now `StyledSheet` (they were a second `Sheet`, easy to
|
|
8
|
+
confuse with the headless one). Most code should use `BottomSheet`; for a close button inside it, `Sheet.Close`
|
|
9
|
+
becomes `BottomSheet.Close`. The headless `Sheet` from `@yourrealm/bottom-sheet` is unchanged.
|
|
10
|
+
- `BottomSheet` needs a name: `title`, or `aria-label` for a sheet without a visible title. A `title` that may be
|
|
11
|
+
`undefined` needs a fallback (`title={q ?? 'Confirm'}`).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `footer` (`Sheet.Footer` / `StyledSheet.Footer`): actions pinned to the bottom of the screen, also at partial
|
|
16
|
+
detents, reaching the sheet's edges, taking the home-bar safe area.
|
|
17
|
+
- `BottomSheet.Edge` (`StyledSheet.Edge`): content in the body that reaches the sheet's edges, its contents kept in
|
|
18
|
+
line with the body. `--ybs-body-inset` is the side space in use.
|
|
19
|
+
- `StyledSheet.Body`: the padded content area for parts users.
|
|
20
|
+
- The `'header'` detent: a peek of exactly the header. A flick down from higher up lands on it instead of closing.
|
|
21
|
+
- `onExitComplete`: called once the sheet has finished closing and left the page.
|
|
22
|
+
- `closeButton` (a visible ✕) and `closeLabel`. Without `closeButton`, a dismissible sheet gets a close button only
|
|
23
|
+
screen readers announce.
|
|
24
|
+
- `BottomSheet.Close`.
|
|
25
|
+
- `eyebrow`: a small label above the title.
|
|
26
|
+
- `--ybs-title-size` token.
|
|
27
|
+
- A sheet without a visible title (`aria-label`): the header is just the grabber, without a title's space.
|
|
28
|
+
- `data-ybs-more` on the root while content is hidden behind the footer.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- `/styled`: the line under the header shows only while content is scrolled under it (it used to always show, and
|
|
33
|
+
content sat right on it). If you added top padding to work around that, you can drop it.
|
|
34
|
+
- `/styled` `floating`: the side spacing includes the gap (24px instead of 16px), so text no longer sits 8px from the
|
|
35
|
+
visible edge at lower detents.
|
|
36
|
+
- A pixel peek like `detents={['76px']}` that was meant to show just the header can become `['header']`.
|
|
37
|
+
|
|
38
|
+
## 0.1.0 (2026-11-01)
|
|
39
|
+
|
|
40
|
+
First release.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yourrealm
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,355 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @yourrealm/bottom-sheet
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Native-feeling bottom sheets for the web, for React.
|
|
4
|
+
|
|
5
|
+
**[Try the demos on your phone →](https://yourrealm.github.io/bottom-sheet/)**
|
|
6
|
+
|
|
7
|
+
## Why another sheet?
|
|
8
|
+
|
|
9
|
+
On iOS and Android, a sheet and the list inside it act as **one gesture**. Drag up and the sheet expands, then the
|
|
10
|
+
list scrolls, without lifting your finger. Scroll back to the top and keep pulling: the sheet follows, with real
|
|
11
|
+
momentum.
|
|
12
|
+
|
|
13
|
+
Most web sheets put a JavaScript-dragged panel around a separate scroll box. The browser can't hand a gesture from
|
|
14
|
+
one to the other mid-touch, so you get dead zones, lost momentum, or "drag from the handle only".
|
|
15
|
+
|
|
16
|
+
This library puts the sheet's travel **and** its content in one native scroll container. Resting positions
|
|
17
|
+
(detents) are scroll-snap points. Momentum, rubber-banding and velocity come from the browser itself; a small
|
|
18
|
+
controller only handles what native sheets also do on top: stopping a fling at the full detent, animating opens and
|
|
19
|
+
closes, following the on-screen keyboard, and re-snapping when the layout changes.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pnpm add @yourrealm/bottom-sheet
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires React 19. Import the CSS once, before Tailwind:
|
|
28
|
+
|
|
29
|
+
```css
|
|
30
|
+
@import "@yourrealm/bottom-sheet/styled.css"; /* or styles.css for the headless parts alone */
|
|
31
|
+
@import "tailwindcss";
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or `import '@yourrealm/bottom-sheet/styled.css'` in your entry, before your own CSS. It's a plain stylesheet served from
|
|
35
|
+
your own origin, so it works under a strict Content-Security-Policy. Everything is in the `ybs` cascade layer, between
|
|
36
|
+
Tailwind v4's `base` and `components`: your own CSS and utility classes on the parts win, and preflight doesn't reset
|
|
37
|
+
the preset. Opening a sheet without the CSS logs an error naming the import.
|
|
38
|
+
|
|
39
|
+
## Use
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { BottomSheet } from '@yourrealm/bottom-sheet/styled'
|
|
43
|
+
|
|
44
|
+
<BottomSheet
|
|
45
|
+
trigger={(props) => <Button {...props}>Filters</Button>} // your own button; or control it with open/onOpenChange
|
|
46
|
+
title="Filters"
|
|
47
|
+
detents={[0.5]}
|
|
48
|
+
>
|
|
49
|
+
…
|
|
50
|
+
</BottomSheet>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Three ways in. **Use `BottomSheet`** unless you've been asked for something else:
|
|
54
|
+
|
|
55
|
+
| | Import from | What it is |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `BottomSheet` | `@yourrealm/bottom-sheet/styled` | The finished sheet. Everything you need hangs off it (`BottomSheet.Close`, `BottomSheet.Edge`). |
|
|
58
|
+
| `StyledSheet` | `@yourrealm/bottom-sheet/styled` | The styled parts `BottomSheet` is built from, to arrange yourself. |
|
|
59
|
+
| `Sheet` | `@yourrealm/bottom-sheet` | The parts with no look at all, only the behaviour. For a fully custom design. |
|
|
60
|
+
|
|
61
|
+
`BottomSheet`'s options (`leading`, `trailing`, `eyebrow`, `headerExtra`, `footer`, `BottomSheet.Edge`) cover most
|
|
62
|
+
layouts. Use the parts only when the person you're working for asks for them, or for a fully custom design (see
|
|
63
|
+
"Building from parts" below).
|
|
64
|
+
|
|
65
|
+
**Migrating an existing sheet or dialog?** Move its content into `BottomSheet`, and map its header to `title`,
|
|
66
|
+
`description`, `leading`, `trailing` and its buttons to `footer`. Don't rebuild your old markup from parts to keep the
|
|
67
|
+
diff small: you lose the screen-reader close button, the header layouts and the eyebrow, and your spacing drifts
|
|
68
|
+
from the preset.
|
|
69
|
+
|
|
70
|
+
### `BottomSheet`
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import { BottomSheet } from '@yourrealm/bottom-sheet/styled'
|
|
74
|
+
|
|
75
|
+
// A confirm sheet with actions pinned to the bottom
|
|
76
|
+
<BottomSheet open={open} onOpenChange={setOpen} title="Delete this list?"
|
|
77
|
+
footer={<><BottomSheet.Close>Cancel</BottomSheet.Close><button onClick={remove}>Delete</button></>}>
|
|
78
|
+
This can't be undone.
|
|
79
|
+
</BottomSheet>
|
|
80
|
+
|
|
81
|
+
// Content that reaches the sheet's edges
|
|
82
|
+
<BottomSheet open={open} onOpenChange={setOpen} title="Café Mollan" eyebrow="Gamla stan">
|
|
83
|
+
<BottomSheet.Edge className="photos">…</BottomSheet.Edge>
|
|
84
|
+
</BottomSheet>
|
|
85
|
+
|
|
86
|
+
// An iOS page sheet: Cancel / title / Save
|
|
87
|
+
<BottomSheet open={open} onOpenChange={setOpen} variant="page" title="New Note"
|
|
88
|
+
leading={<BottomSheet.Close>Cancel</BottomSheet.Close>} trailing={<button onClick={save}>Save</button>}>
|
|
89
|
+
…
|
|
90
|
+
</BottomSheet>
|
|
91
|
+
|
|
92
|
+
// No visible title: name it with aria-label (description, leading/trailing still work)
|
|
93
|
+
<BottomSheet open={open} onOpenChange={setOpen} aria-label="Photo options">
|
|
94
|
+
…
|
|
95
|
+
</BottomSheet>
|
|
96
|
+
|
|
97
|
+
// A persistent result sheet: peeks with just its header, never closes by gesture
|
|
98
|
+
<BottomSheet open={solved} modal={false} dismissible={false} detents={['header']} title="Solved">
|
|
99
|
+
…
|
|
100
|
+
</BottomSheet>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- **A name is required:** `title` (visible, and it names the dialog for screen readers), or `aria-label` for a sheet
|
|
104
|
+
without a visible title (an action list, a photo viewer). TypeScript refuses a sheet with neither. A title that may
|
|
105
|
+
be missing needs a fallback: `title={pending?.question ?? 'Confirm'}`.
|
|
106
|
+
- `eyebrow`: a small label above the title (a section, a category), read together with the title. Needs `title`.
|
|
107
|
+
- `description`: a line under the title; it describes the dialog.
|
|
108
|
+
- `leading` / `trailing`: header slots, iOS navigation-bar style (Cancel / title / Save, the title centred).
|
|
109
|
+
- `headerExtra`: content in the sticky header under the title (a search field).
|
|
110
|
+
- `footer`: actions pinned to the bottom of the screen (Cancel / Delete, Show results). They stay reachable while a
|
|
111
|
+
long sheet scrolls and at partial detents, reach the sheet's edges, and take the home-bar safe area. At a peek
|
|
112
|
+
too low for both, the footer stays tucked under the header and slides in as the sheet rises.
|
|
113
|
+
- `closeButton`: a visible ✕ in the header's top right (off by default). Without it, a dismissible sheet still has a
|
|
114
|
+
close button that only screen readers announce (shown when focused by keyboard), because VoiceOver users can't swipe a
|
|
115
|
+
sheet away or tap the dimmed area. `closeLabel` names either one (default `'Close'`).
|
|
116
|
+
- `BottomSheet.Close`: a button that closes the sheet (in `leading`, `footer`, or the content).
|
|
117
|
+
- `BottomSheet.Edge`: content in the body that reaches the sheet's edges (a photo strip that scrolls sideways, a
|
|
118
|
+
full-width tappable row, a separator), its contents kept in line with the body. Right in every variant.
|
|
119
|
+
- `trigger`: text or elements go inside a plain, unstyled `<button class="ybs-trigger">`. For a design-system button,
|
|
120
|
+
pass a function; it receives `type`, `onClick` and the ARIA state:
|
|
121
|
+
`trigger={(props) => <Button variant="outline" {...props}>Filters</Button>}`. Or leave `trigger` out and control `open`.
|
|
122
|
+
- `variant="page"`: an iOS page sheet for a task: full width, the page behind recedes (`recede`), and the sheet fills
|
|
123
|
+
the screen at full even with short content.
|
|
124
|
+
- `width`: `'card'` (default for sheets: at most 640px, centred) or `'full'` (default for pages: spans the window,
|
|
125
|
+
content in a centred 960px column).
|
|
126
|
+
- `floating`: iOS 26 style, inset from the screen edges with large corners at lower detents, attaching as it reaches
|
|
127
|
+
full. Its side spacing includes the gap, so text keeps its distance from the visible edge.
|
|
128
|
+
- `initialFocus`: see Options.
|
|
129
|
+
- `className` / `contentProps`: go on the sheet element (`<section role="dialog">`).
|
|
130
|
+
- Plus every option below.
|
|
131
|
+
|
|
132
|
+
### Options (on `BottomSheet` and `Sheet.Root`)
|
|
133
|
+
|
|
134
|
+
- `detents`: partial resting heights, as fractions of the height available to the sheet (`0.5`), CSS lengths of
|
|
135
|
+
visible sheet (`'76px'`, `'40dvh'`), or `'header'`: exactly the header, measured, for a peek that shows the title
|
|
136
|
+
and nothing under it. A `'header'` peek is a minimised state: a flick down from higher up lands on it, and only a
|
|
137
|
+
flick from the peek itself closes the sheet (with `dismissible={false}`, nothing does). Other partial detents let a
|
|
138
|
+
flick run past them. Full height (`'full'`) is always included, and is the content's own height when the content
|
|
139
|
+
is short. Default `[]`: the sheet opens straight to full, like iOS.
|
|
140
|
+
- `initialDetent`: where it opens: `'full'` or one of your `detents`. Default: the smallest partial detent, else full.
|
|
141
|
+
- `detent` / `onDetentChange`: the detent the sheet rests at, `'full'` or the value you passed in `detents`
|
|
142
|
+
(`0.5`, `'76px'`, `'header'`). Pass `detent` to control it (changing it moves the sheet). `onDetentChange` never
|
|
143
|
+
reports `'closed'` (closing is `onOpenChange(false)`), so `detent={d} onDetentChange={setD}` round-trips.
|
|
144
|
+
- `open` / `defaultOpen` / `onOpenChange(open, { reason })`: controlled or uncontrolled. The reason is
|
|
145
|
+
`'trigger' | 'close-button' | 'escape' | 'back' | 'outside-press' | 'swipe'`. Use `open` when a sheet has several
|
|
146
|
+
openers, or opening runs your own logic.
|
|
147
|
+
- `onExitComplete`: called once the sheet has finished closing and left the page. See "Closing animates".
|
|
148
|
+
- `modal` (default `true`): `false` leaves the page usable around the sheet: no backdrop (`BottomSheet` drops it), no
|
|
149
|
+
inert page, no scroll lock. It doesn't move focus unless you pass `initialFocus`, and Escape only acts on it while
|
|
150
|
+
focus is inside it. `modal` is read when the sheet opens; a change while it's open applies from the next open.
|
|
151
|
+
On iOS, each direct child of a non-modal sheet becomes a 1px horizontal scroller (Safari needs a scroll container
|
|
152
|
+
under the finger to drag the sheet): keep sticky elements at the top level, like the header, and render popovers in
|
|
153
|
+
a portal rather than inside the sheet's children.
|
|
154
|
+
- `initialFocus` (on `BottomSheet` / `Sheet.Content`, like Base UI's): where focus goes when the sheet opens. A ref,
|
|
155
|
+
`false`, `true` (the sheet), or a function of how it was opened: `initialFocus={(type) => type !== 'touch' &&
|
|
156
|
+
newBoardRef.current}` focuses a button for mouse and keyboard users only. Default: the sheet for a modal sheet,
|
|
157
|
+
nothing for a non-modal one. A non-modal sheet that took focus gives it back when it closes.
|
|
158
|
+
- `dismissible` (default `true`): `false` means a swipe down, Escape or an outside press drop the sheet to its lowest
|
|
159
|
+
detent instead of closing it. `open={false}` and a close button still close it.
|
|
160
|
+
- `closeOnBack` (default: `modal`): the Android back button closes the top sheet, like a native sheet. Built on
|
|
161
|
+
[`CloseWatcher`](https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher), so it adds no history entries and
|
|
162
|
+
never touches your URLs or router. Works in Chrome and Firefox on Android; Safari has no `CloseWatcher` yet, and
|
|
163
|
+
elsewhere it does nothing.
|
|
164
|
+
- `fullscreen`: the full detent reaches the very top of the screen, and the sheet fills the screen there even with
|
|
165
|
+
short content. `/styled` squares off the corners as it arrives and pads the header by the top safe area.
|
|
166
|
+
- `recede`: the page behind scales back like iOS while the sheet covers the screen. Mark your app's wrapper with
|
|
167
|
+
`data-ybs-background` and give it a background. While receding, `position: fixed` elements inside that wrapper are
|
|
168
|
+
positioned against it. Modal sheets only. On by default for `variant="page"`.
|
|
169
|
+
- `debug`: a function that receives the sheet's internal decision log, for testing gestures on a device.
|
|
170
|
+
|
|
171
|
+
### Closing animates
|
|
172
|
+
|
|
173
|
+
A sheet animates out after `open` turns false, so **keep it rendered**. Don't write `{open && <BottomSheet … />}`:
|
|
174
|
+
removing it skips the animation (it just vanishes). Whatever the content shows (the question in a confirm sheet,
|
|
175
|
+
the item being shared) has to stay around until the sheet is gone. `onExitComplete` tells you when:
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
import { useState } from 'react'
|
|
179
|
+
import { BottomSheet } from '@yourrealm/bottom-sheet/styled'
|
|
180
|
+
|
|
181
|
+
function useConfirm() {
|
|
182
|
+
const [pending, setPending] = useState<{ question: string; onConfirm: () => void } | null>(null)
|
|
183
|
+
const [open, setOpen] = useState(false)
|
|
184
|
+
const confirm = (question: string, onConfirm: () => void) => {
|
|
185
|
+
setPending({ question, onConfirm })
|
|
186
|
+
setOpen(true)
|
|
187
|
+
}
|
|
188
|
+
const sheet = (
|
|
189
|
+
<BottomSheet
|
|
190
|
+
open={open}
|
|
191
|
+
onOpenChange={setOpen}
|
|
192
|
+
onExitComplete={() => setPending(null)} // the sheet is gone: now it's safe to clear
|
|
193
|
+
title={pending?.question ?? 'Confirm'}
|
|
194
|
+
footer={<>
|
|
195
|
+
<BottomSheet.Close>Cancel</BottomSheet.Close>
|
|
196
|
+
<button onClick={() => { pending?.onConfirm(); setOpen(false) }}>Confirm</button>
|
|
197
|
+
</>}
|
|
198
|
+
/>
|
|
199
|
+
)
|
|
200
|
+
return { confirm, sheet } // render {sheet} once; call confirm('Delete this list?', remove)
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`onOpenChange` fires for what the user does (a trigger click, a swipe, Escape, back, a tap outside, a close button),
|
|
205
|
+
never when you change `open` yourself, so `onOpenChange={(o) => !o && onClose()}` can't loop.
|
|
206
|
+
|
|
207
|
+
### Styling
|
|
208
|
+
|
|
209
|
+
- Light and dark follow your app's `color-scheme` (inherited from `<html>`), so a theme switch that sets it gets a
|
|
210
|
+
matching sheet. Set `html { color-scheme: light dark }` to follow the phone's setting; with no `color-scheme` at all
|
|
211
|
+
the sheet stays light. One sheet in a fixed theme: `className="[color-scheme:dark]"` or
|
|
212
|
+
`contentProps={{ style: { colorScheme: 'dark' } }}`.
|
|
213
|
+
- Tokens. Set them on `[data-ybs-root]`, and point them at your own variables to match your theme (dark mode then
|
|
214
|
+
comes with it): `[data-ybs-root] { --ybs-sheet-bg: var(--card); --ybs-sheet-fg: var(--card-foreground); }`.
|
|
215
|
+
|
|
216
|
+
| Token | Default (light / dark) | What it affects |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `--ybs-sheet-bg` | `#fff` / `#1c1c1e` | Sheet background, also behind the keyboard |
|
|
219
|
+
| `--ybs-sheet-fg` | `#111` / `#f2f2f7` | Text colour |
|
|
220
|
+
| `--ybs-muted` | `#6b6b70` / `#98989f` | Description, eyebrow, the ✕ |
|
|
221
|
+
| `--ybs-handle` | grey, 30% | The grabber at the top |
|
|
222
|
+
| `--ybs-divider` | grey, 12% | Lines under the header and above the footer |
|
|
223
|
+
| `--ybs-backdrop` | `#000` | Colour of the dimmed page (a colour, no alpha) |
|
|
224
|
+
| `--ybs-backdrop-max` | `0.4` | How dark the dim gets at full. An `rgba(0,0,0,.5)` scrim is `#000` + `0.5` |
|
|
225
|
+
| `--ybs-radius` | `14px` | Top corners |
|
|
226
|
+
| `--ybs-title-size` | `17px` | Title |
|
|
227
|
+
| `--ybs-body-padding` | `16px` | Side space of the body and footer (`0` for edge-to-edge lists that pad their own rows) |
|
|
228
|
+
| `--ybs-max-width` | `640px` | Width of a `card` sheet |
|
|
229
|
+
| `--ybs-content-width` | `960px` | Content column of a `width="full"` sheet |
|
|
230
|
+
| `--ybs-float-gap` / `--ybs-float-radius` | `8px` / `32px` | `floating`: gap to the screen edges, corner size |
|
|
231
|
+
| `--ybs-inset` | safe area + 10px | Gap above the sheet at full (+22px for page sheets, `0` with `fullscreen`). Works headless too |
|
|
232
|
+
|
|
233
|
+
- `--ybs-body-inset` (read it, don't set it) is the side space actually in use, larger on a wide `width="full"` sheet;
|
|
234
|
+
`BottomSheet.Edge` uses it.
|
|
235
|
+
- The header draws its divider only while content is scrolled under it, and the footer only while content is hidden
|
|
236
|
+
behind it, so a sheet at rest has no lines splitting it.
|
|
237
|
+
- The sheet already pads its bottom for the home bar (`safe-area-inset-bottom`, in the footer when there is one).
|
|
238
|
+
Don't add your own.
|
|
239
|
+
|
|
240
|
+
### Behaviour
|
|
241
|
+
|
|
242
|
+
- Opening, closing and the other moves the library makes (a new `detent`, the keyboard appearing, wheel steps) animate
|
|
243
|
+
in about 400ms going up and 280ms going down (Material 3 emphasized easing), and are instant with reduced motion.
|
|
244
|
+
Drags and flings are the browser's own scrolling.
|
|
245
|
+
- With the content scrolled, dragging the header moves the whole sheet and keeps the content where it is, like native.
|
|
246
|
+
- A tap on content that is still scrolling only stops it, and a drag never presses what it started on.
|
|
247
|
+
- Keys scroll the content but never move the sheet down or close it; closing by keyboard is Escape. Escape that ends
|
|
248
|
+
an input method's composition (Chinese, Japanese) is left to the input.
|
|
249
|
+
- Once a sheet starts closing, it finishes: a touch, wheel or drag during the animation doesn't bring it back.
|
|
250
|
+
- A sheet closes from a swipe, Escape, back, an outside press, a close button or `open={false}`, never from a scroll
|
|
251
|
+
nobody asked for: one that lands at closed (the browser revealing a focused field, say) is undone.
|
|
252
|
+
|
|
253
|
+
### Stacked sheets
|
|
254
|
+
|
|
255
|
+
A sheet opened from inside another one stacks on top: with `/styled`, the one below recedes (scales back, like iOS)
|
|
256
|
+
and comes forward again when the top one closes (parts: `data-ybs-covered`, see below). Escape, Android back and a tap on the dimmed area all close only the top sheet.
|
|
257
|
+
The page stays inert until the last modal sheet closes. No setup: render the second `BottomSheet` inside the first.
|
|
258
|
+
|
|
259
|
+
### Building from parts
|
|
260
|
+
|
|
261
|
+
Not sure you need this? Go back to `BottomSheet`.
|
|
262
|
+
|
|
263
|
+
`StyledSheet` (the preset look) and `Sheet` (no look) have the same parts; `StyledSheet` adds `Body` (the padded
|
|
264
|
+
content area) and `Edge`. You assemble what `BottomSheet` does for you, so a few things are yours:
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
import { Sheet } from '@yourrealm/bottom-sheet'
|
|
268
|
+
|
|
269
|
+
<Sheet.Root detents={[0.4]}>
|
|
270
|
+
<Sheet.Trigger>Up next</Sheet.Trigger>
|
|
271
|
+
<Sheet.Portal>
|
|
272
|
+
<Sheet.Backdrop className="backdrop" />
|
|
273
|
+
<Sheet.View>
|
|
274
|
+
<Sheet.Content className="sheet">
|
|
275
|
+
<Sheet.Header className="header">
|
|
276
|
+
<Sheet.Handle />
|
|
277
|
+
<Sheet.Title>Up next</Sheet.Title>
|
|
278
|
+
<Sheet.Close>Done</Sheet.Close>
|
|
279
|
+
</Sheet.Header>
|
|
280
|
+
…
|
|
281
|
+
<Sheet.Footer className="footer">…</Sheet.Footer>
|
|
282
|
+
</Sheet.Content>
|
|
283
|
+
</Sheet.View>
|
|
284
|
+
</Sheet.Portal>
|
|
285
|
+
</Sheet.Root>
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
- **With `StyledSheet`:** put your content in `StyledSheet.Body` after the `Header` (it's the padded area, and
|
|
289
|
+
`StyledSheet.Edge` lines up with it), add `StyledSheet.Backdrop` for a modal sheet, and lay out the header yourself
|
|
290
|
+
(the Cancel / title / Save row, the ✕ and the eyebrow are `BottomSheet` features).
|
|
291
|
+
- **Naming:** `Sheet.Title` and `Sheet.Description` name and describe the dialog. Without a `Title`, give
|
|
292
|
+
`Sheet.Content` an `aria-label` (or `aria-labelledby` pointing at your own heading).
|
|
293
|
+
- **Closing:** add your own `Sheet.Close`; there's no automatic screen-reader close button.
|
|
294
|
+
- **Non-modal:** leave `Backdrop` out yourself (a rendered `Backdrop` still dims and closes on tap).
|
|
295
|
+
- **`Sheet.Portal container`:** render into an element other than `<body>`. A modal sheet still makes everything else
|
|
296
|
+
on the page inert.
|
|
297
|
+
- **`Sheet.View`** passes its props (`className`, `style`, `ref`, `data-*`) to the scroller, the one scroll
|
|
298
|
+
container; `bleedClassName` styles the layer that continues the sheet colour behind the keyboard.
|
|
299
|
+
- **Set by the library on `[data-ybs-root]`**, for your own styling: `--ybs-progress` (0 to 1, closed to full),
|
|
300
|
+
`--ybs-visible` (px of the sheet on screen), and the attributes `data-ybs-scrolled` (content scrolled under the
|
|
301
|
+
header), `data-ybs-more` (content hidden behind the footer), `data-ybs-fit` (the sheet is shorter than the screen),
|
|
302
|
+
`data-ybs-fullscreen`, `data-ybs-modeless` (non-modal), and on the sheet below a stacked one `data-ybs-covered`
|
|
303
|
+
with `--ybs-covered` (0 to 1, following the top sheet).
|
|
304
|
+
- **Recede headless:** style `html[data-ybs-recede] [data-ybs-background]` with `--ybs-recede` (0 to 1, how much of
|
|
305
|
+
the screen the sheet covers) and `--ybs-scroll-y` (for a `transform-origin` at the top of the screen).
|
|
306
|
+
- **Headless sticky header:** content scrolling under it can show a 1px sliver above it; cover it with
|
|
307
|
+
`box-shadow: 0 -2px 0 0 <sheet colour>` on the header (only while `data-ybs-scrolled` if your sheet has a border).
|
|
308
|
+
- Don't put `touch-action` classes on the header (Tailwind's utilities layer beats ours): the library turns it off
|
|
309
|
+
there while the content is scrolled, so that dragging the header moves the whole sheet.
|
|
310
|
+
- **Types** for wrappers: `Detent`, `OpenChangeReason`, `OpenChangeDetails`, `RootProps`, `ContentProps`,
|
|
311
|
+
`InitialFocus` and the other part props from both entry points, and `BottomSheetProps` from `/styled`.
|
|
312
|
+
|
|
313
|
+
### Replacing a `<dialog>`
|
|
314
|
+
|
|
315
|
+
| `<dialog>` | bottom-sheet |
|
|
316
|
+
|---|---|
|
|
317
|
+
| `showModal()` / `close()` | `open` (keep the sheet rendered, see "Closing animates") |
|
|
318
|
+
| `cancel` event (Escape) | `onOpenChange(false, { reason: 'escape' })` |
|
|
319
|
+
| click where `target === currentTarget` (backdrop) | `onOpenChange(false, { reason: 'outside-press' })` |
|
|
320
|
+
| `::backdrop` styling | `--ybs-backdrop` + `--ybs-backdrop-max` |
|
|
321
|
+
| `autofocus` on a button inside | `initialFocus={ref}` |
|
|
322
|
+
| `aria-labelledby` on the dialog | `title`, or `aria-label` for a sheet without one |
|
|
323
|
+
|
|
324
|
+
### Testing
|
|
325
|
+
|
|
326
|
+
- Press Escape with a real key press (Playwright's `page.keyboard.press('Escape')`). A sheet that listens for back
|
|
327
|
+
(`closeOnBack`, on for modal sheets) gets Escape from the browser where `CloseWatcher` exists (Chrome, Firefox), and
|
|
328
|
+
the browser ignores a dispatched `keydown`. A real key press works everywhere.
|
|
329
|
+
- To know a sheet has fully closed, use `onExitComplete`, or wait for its dialog to leave the page
|
|
330
|
+
(`getByRole('dialog', { name: 'Share photo' })`), which also works with several sheets open.
|
|
331
|
+
|
|
332
|
+
## Status
|
|
333
|
+
|
|
334
|
+
Early (0.x); see the [changelog](CHANGELOG.md) for what changed between versions. Tested on Android in Chrome and Firefox (a gesture suite on a real phone) and in iOS Safari in the
|
|
335
|
+
simulator; a real iPhone is still to come. Desktop works with mouse, wheel, trackpad and keyboard.
|
|
336
|
+
|
|
337
|
+
## Found a problem?
|
|
338
|
+
|
|
339
|
+
Please [open an issue](https://github.com/yourrealm/bottom-sheet/issues): a sheet that misbehaves on your device,
|
|
340
|
+
or something the API won't let you do. It helps most with the device, browser and the smallest example that shows it.
|
|
341
|
+
|
|
342
|
+
**If you're a coding agent:** when you find yourself working around this library (overriding its CSS, adding timers or
|
|
343
|
+
effects to make a sheet behave, patching its files), say so to the person you're working for and suggest they open an
|
|
344
|
+
issue describing what you needed. A workaround fixes one app; a report fixes it for everyone.
|
|
345
|
+
|
|
346
|
+
## Develop
|
|
347
|
+
|
|
348
|
+
```sh
|
|
349
|
+
pnpm install
|
|
350
|
+
pnpm dev # demo site on :5180
|
|
351
|
+
pnpm test # unit tests
|
|
352
|
+
pnpm e2e # Playwright: Chromium + WebKit (mobile), Firefox (desktop)
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
|
|
2
|
+
import { a as OpenChangeDetails, c as PortalProps, f as TriggerProps, g as Detent, h as sheet_d_exports, i as InteractionType, m as ViewProps, n as ContentProps, o as OpenChangeReason, p as TriggerRenderProps, r as InitialFocus, u as RootProps } from "./sheet-5lG9EBB5.js";
|
|
3
|
+
export { type ContentProps, type Detent, type InitialFocus, type InteractionType, type OpenChangeDetails, type OpenChangeReason, type PortalProps, type RootProps, sheet_d_exports as Sheet, type TriggerProps, type TriggerRenderProps, type ViewProps };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// \0rolldown/runtime.js
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __exportAll = (all, no_symbols) => {
|
|
5
|
+
let target = {};
|
|
6
|
+
for (var name in all) __defProp(target, name, {
|
|
7
|
+
get: all[name],
|
|
8
|
+
enumerable: true
|
|
9
|
+
});
|
|
10
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
11
|
+
return target;
|
|
12
|
+
};
|
|
13
|
+
export { __exportAll as t };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
|
|
2
|
+
import { ComponentPropsWithRef, MouseEvent, ReactElement, ReactNode, RefObject } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* A resting position.
|
|
5
|
+
* - `'closed'` and `'full'` are always available (`'closed'` only while the sheet is dismissible).
|
|
6
|
+
* - A number is a fraction (0..1) of the available height that is visible, e.g. `0.5`.
|
|
7
|
+
* - `'header'` shows exactly the sheet's header (`Sheet.Header`), measured, e.g. a peek with just the title.
|
|
8
|
+
* - Any other string is a CSS length of visible sheet, e.g. `'76px'`, `'40dvh'`, `'min(320px, 50dvh)'`.
|
|
9
|
+
*/
|
|
10
|
+
type Detent = 'closed' | 'full' | 'header' | number | (string & {});
|
|
11
|
+
declare namespace sheet_d_exports {
|
|
12
|
+
export { Backdrop, Close, Content, ContentProps, Description, Footer, Handle, Header, InitialFocus, InteractionType, OpenChangeDetails, OpenChangeReason, Portal, PortalProps, Root, RootProps, Title, Trigger, TriggerProps, TriggerRenderProps, View, ViewProps };
|
|
13
|
+
}
|
|
14
|
+
/** Why the sheet is asking to open or close. */
|
|
15
|
+
type OpenChangeReason = 'trigger' | 'close-button' | 'escape' | 'back' | 'outside-press' | 'swipe';
|
|
16
|
+
interface OpenChangeDetails {
|
|
17
|
+
reason: OpenChangeReason;
|
|
18
|
+
}
|
|
19
|
+
interface RootProps {
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
open?: boolean;
|
|
22
|
+
defaultOpen?: boolean;
|
|
23
|
+
/** Called when the user opens or closes the sheet (not when the `open` prop changes). */
|
|
24
|
+
onOpenChange?: (open: boolean, details: OpenChangeDetails) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Called once the sheet has finished closing (animation done, removed from the page). Keep the sheet rendered with
|
|
27
|
+
* `open={false}` so it can animate out, and clear what its content needs here, e.g. `() => setPending(null)`.
|
|
28
|
+
* Unmounting the sheet (`{open && <BottomSheet/>}`) skips the animation.
|
|
29
|
+
*/
|
|
30
|
+
onExitComplete?: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* Partial detents; `full` is always included. Default `[]`: the sheet opens straight to full (fit to short content).
|
|
33
|
+
* Each is a fraction of the available height (0.5), a CSS length of visible sheet ('76px', '40dvh'), or `'header'`
|
|
34
|
+
* (exactly the header: a peek that shows the title and nothing under it).
|
|
35
|
+
*/
|
|
36
|
+
detents?: readonly Detent[];
|
|
37
|
+
/**
|
|
38
|
+
* Modal (default): a backdrop, the rest of the page inert, page scroll locked, focus moved into the sheet.
|
|
39
|
+
* Non-modal: the page stays visible and usable around the sheet; taps outside it go to the page, and Escape only
|
|
40
|
+
* acts while focus is inside the sheet. Read when the sheet opens: changing it while open applies on the next open.
|
|
41
|
+
*/
|
|
42
|
+
modal?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a swipe down, Escape or an outside press can close the sheet (default true). When false they drop it
|
|
45
|
+
* to its lowest detent instead; `open={false}` and `<Sheet.Close>` still close it.
|
|
46
|
+
*/
|
|
47
|
+
dismissible?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Android back closes the sheet (the top one when stacked), like a native sheet; on a sheet that isn't
|
|
50
|
+
* dismissible it drops to the lowest detent. Default: true for modal sheets, false for non-modal ones.
|
|
51
|
+
* Uses `CloseWatcher` (Chrome, Firefox), so no history entries are added and URLs are never touched.
|
|
52
|
+
* Browsers without it (Safari) ignore this. The browser may let back navigate instead when the page
|
|
53
|
+
* hasn't been interacted with since the sheet opened (its anti-trap rule).
|
|
54
|
+
*/
|
|
55
|
+
closeOnBack?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* The full detent reaches the very top of the screen (no gap above the sheet). Sets `data-ybs-fullscreen` on the
|
|
58
|
+
* root; `/styled` then squares off the corners as the sheet arrives at the top and pads the header by the top
|
|
59
|
+
* safe area. Headless: pad your header with `env(safe-area-inset-top)` yourself.
|
|
60
|
+
*/
|
|
61
|
+
fullscreen?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* The page behind recedes while this modal sheet is open, like iOS. Mark your app's wrapper with
|
|
64
|
+
* `data-ybs-background`; `/styled` scales it back and rounds its top corners. Headless: style
|
|
65
|
+
* `html[data-ybs-recede] [data-ybs-background]` with `--ybs-recede` (0..1) and `--ybs-scroll-y`.
|
|
66
|
+
* While receding, `position: fixed` elements inside the wrapper are positioned against it.
|
|
67
|
+
*/
|
|
68
|
+
recede?: boolean;
|
|
69
|
+
/** Detent to open at. Default: the smallest usable partial detent, else `full`. */
|
|
70
|
+
initialDetent?: Detent;
|
|
71
|
+
/** Controlled resting detent. Changing it moves the sheet. */
|
|
72
|
+
detent?: Detent;
|
|
73
|
+
/** The sheet came to rest at a detent. Never `'closed'`: closing is `onOpenChange(false)`. */
|
|
74
|
+
onDetentChange?: (detent: Detent) => void;
|
|
75
|
+
/** Receives the controller's internal decision log (for device testing). */
|
|
76
|
+
debug?: (message: string) => void;
|
|
77
|
+
}
|
|
78
|
+
/** How the sheet was opened, as Base UI reports it to `initialFocus`. */
|
|
79
|
+
type InteractionType = 'mouse' | 'touch' | 'pen' | 'keyboard';
|
|
80
|
+
/**
|
|
81
|
+
* Where focus goes when the sheet opens (Base UI's `initialFocus`). Omitted: the sheet itself for a modal sheet, no
|
|
82
|
+
* move for a non-modal one. `false`: don't move it. `true`: the sheet itself. A ref: that element. A function: called
|
|
83
|
+
* with how the sheet was opened; return an element, `true` for the sheet, `null` to fall back to the default, or
|
|
84
|
+
* `false`/`undefined` to do nothing.
|
|
85
|
+
*/
|
|
86
|
+
type InitialFocus = boolean | RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | void | HTMLElement | null);
|
|
87
|
+
declare function Root(props: RootProps): import("react").JSX.Element;
|
|
88
|
+
/** What `Trigger`'s `render` function receives: spread it onto your own button. */
|
|
89
|
+
interface TriggerRenderProps {
|
|
90
|
+
type: 'button';
|
|
91
|
+
'aria-haspopup': 'dialog';
|
|
92
|
+
'aria-expanded': boolean;
|
|
93
|
+
onClick: (e: MouseEvent<HTMLElement>) => void;
|
|
94
|
+
}
|
|
95
|
+
interface TriggerProps extends ComponentPropsWithRef<'button'> {
|
|
96
|
+
/**
|
|
97
|
+
* Render your own button (a design-system `<Button>`) instead of ours:
|
|
98
|
+
* `render={(props) => <Button variant="paper" {...props}>Open</Button>}`. It receives `type`, `onClick` and the
|
|
99
|
+
* ARIA state; anything else (className, disabled, aria-label) goes on your button directly.
|
|
100
|
+
*/
|
|
101
|
+
render?: (props: TriggerRenderProps) => ReactElement;
|
|
102
|
+
}
|
|
103
|
+
declare function Trigger({ render, ...props }: TriggerProps): import("react").JSX.Element;
|
|
104
|
+
declare function Close(props: ComponentPropsWithRef<'button'>): import("react").JSX.Element;
|
|
105
|
+
interface PortalProps {
|
|
106
|
+
children?: ReactNode;
|
|
107
|
+
/** Where to render. Default document.body. */
|
|
108
|
+
container?: HTMLElement | null;
|
|
109
|
+
}
|
|
110
|
+
/** Fixed layer. Mounted while open or animating closed. A modal sheet makes the rest of the page inert. */
|
|
111
|
+
declare function Portal({ children, container }: PortalProps): import("react").ReactPortal | null;
|
|
112
|
+
declare function Backdrop(props: ComponentPropsWithRef<'div'>): import("react").JSX.Element;
|
|
113
|
+
/** Props go on the scroller (the one scroll container); `bleedClassName` on the bleed layer. */
|
|
114
|
+
interface ViewProps extends ComponentPropsWithRef<'div'> {
|
|
115
|
+
/** className for the bleed layer (sheet colour under the visual viewport). */
|
|
116
|
+
bleedClassName?: string;
|
|
117
|
+
}
|
|
118
|
+
/** The single native scroller: spacer (closed area), detent markers, and the sheet (Sheet.Content). */
|
|
119
|
+
declare function View({ children, bleedClassName, ref, ...props }: ViewProps): import("react").JSX.Element;
|
|
120
|
+
interface ContentProps extends ComponentPropsWithRef<'section'> {
|
|
121
|
+
/** Where focus goes when the sheet opens (like Base UI's `initialFocus`). See `InitialFocus`. */
|
|
122
|
+
initialFocus?: InitialFocus;
|
|
123
|
+
}
|
|
124
|
+
declare function Content({ ref, initialFocus, ...props }: ContentProps): import("react").JSX.Element;
|
|
125
|
+
/** Sticky top part of the sheet. Mouse-draggable on desktop. */
|
|
126
|
+
declare function Header(props: ComponentPropsWithRef<'div'>): import("react").JSX.Element;
|
|
127
|
+
/**
|
|
128
|
+
* Sticky bottom part of the sheet, for actions. It stays at the bottom of the screen while the content scrolls, and at
|
|
129
|
+
* partial detents too. The controller sets `data-ybs-more` on the root while content is hidden behind it.
|
|
130
|
+
*/
|
|
131
|
+
declare function Footer(props: ComponentPropsWithRef<'div'>): import("react").JSX.Element;
|
|
132
|
+
/** Visual grabber. Decorative; dragging works on the whole Header. */
|
|
133
|
+
declare function Handle(props: ComponentPropsWithRef<'div'>): import("react").JSX.Element;
|
|
134
|
+
declare function Title(props: ComponentPropsWithRef<'h2'>): import("react").JSX.Element;
|
|
135
|
+
declare function Description(props: ComponentPropsWithRef<'p'>): import("react").JSX.Element;
|
|
136
|
+
export { OpenChangeDetails as a, PortalProps as c, Trigger as d, TriggerProps as f, Detent as g, sheet_d_exports as h, InteractionType as i, Root as l, ViewProps as m, ContentProps as n, OpenChangeReason as o, TriggerRenderProps as p, InitialFocus as r, Portal as s, Close as t, RootProps as u };
|