@yourrealm/bottom-sheet 0.1.0 → 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/README.md +247 -80
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/{sheet-BOTt16RW.d.ts → sheet-5lG9EBB5.d.ts} +16 -3
- package/dist/{sheet-BgvWfCR8.js → sheet-C1fUpy71.js} +48 -4
- package/dist/styled.css +60 -5
- package/dist/styled.d.ts +48 -7
- package/dist/styled.js +78 -31
- package/dist/styles.css +9 -1
- package/package.json +5 -4
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/README.md
CHANGED
|
@@ -38,122 +38,210 @@ the preset. Opening a sheet without the CSS logs an error naming the import.
|
|
|
38
38
|
|
|
39
39
|
## Use
|
|
40
40
|
|
|
41
|
-
The simple path, with the polished preset:
|
|
42
|
-
|
|
43
41
|
```tsx
|
|
44
42
|
import { BottomSheet } from '@yourrealm/bottom-sheet/styled'
|
|
45
43
|
|
|
46
|
-
<BottomSheet
|
|
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
|
+
>
|
|
47
49
|
…
|
|
48
50
|
</BottomSheet>
|
|
49
51
|
```
|
|
50
52
|
|
|
51
|
-
|
|
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`
|
|
52
71
|
|
|
53
72
|
```tsx
|
|
54
|
-
import {
|
|
73
|
+
import { BottomSheet } from '@yourrealm/bottom-sheet/styled'
|
|
55
74
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
</Sheet.Header>
|
|
67
|
-
…
|
|
68
|
-
</Sheet.Content>
|
|
69
|
-
</Sheet.View>
|
|
70
|
-
</Sheet.Portal>
|
|
71
|
-
</Sheet.Root>
|
|
72
|
-
```
|
|
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>
|
|
73
85
|
|
|
74
|
-
|
|
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>
|
|
75
91
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
80
144
|
- `open` / `defaultOpen` / `onOpenChange(open, { reason })`: controlled or uncontrolled. The reason is
|
|
81
145
|
`'trigger' | 'close-button' | 'escape' | 'back' | 'outside-press' | 'swipe'`. Use `open` when a sheet has several
|
|
82
146
|
openers, or opening runs your own logic.
|
|
83
|
-
- `
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
design-system `<Button>`), receiving `type`, `onClick` and the ARIA state:
|
|
88
|
-
`trigger={(props) => <Button variant="outline" {...props}>Filters</Button>}`. Headless: `<Sheet.Trigger render={…} />`.
|
|
89
|
-
- `modal` (default `true`): `false` gives a sheet without a backdrop that leaves the page usable around it (no inert,
|
|
90
|
-
no scroll lock). It doesn't move focus unless you pass `initialFocus`, and Escape only acts on it while focus is
|
|
91
|
-
inside it. `modal` is read when the sheet opens; a change while it's open applies from the next open.
|
|
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.
|
|
92
151
|
On iOS, each direct child of a non-modal sheet becomes a 1px horizontal scroller (Safari needs a scroll container
|
|
93
|
-
under the finger to drag the sheet): keep sticky elements at the top level, like
|
|
94
|
-
|
|
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.
|
|
95
154
|
- `initialFocus` (on `BottomSheet` / `Sheet.Content`, like Base UI's): where focus goes when the sheet opens. A ref,
|
|
96
155
|
`false`, `true` (the sheet), or a function of how it was opened: `initialFocus={(type) => type !== 'touch' &&
|
|
97
156
|
newBoardRef.current}` focuses a button for mouse and keyboard users only. Default: the sheet for a modal sheet,
|
|
98
157
|
nothing for a non-modal one. A non-modal sheet that took focus gives it back when it closes.
|
|
99
158
|
- `dismissible` (default `true`): `false` means a swipe down, Escape or an outside press drop the sheet to its lowest
|
|
100
|
-
detent instead of closing it. `open={false}` and
|
|
159
|
+
detent instead of closing it. `open={false}` and a close button still close it.
|
|
101
160
|
- `closeOnBack` (default: `modal`): the Android back button closes the top sheet, like a native sheet. Built on
|
|
102
161
|
[`CloseWatcher`](https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher), so it adds no history entries and
|
|
103
162
|
never touches your URLs or router. Works in Chrome and Firefox on Android; Safari has no `CloseWatcher` yet, and
|
|
104
163
|
elsewhere it does nothing.
|
|
105
|
-
- `fullscreen`: the full detent reaches the very top of the screen, and the sheet fills the screen there even with
|
|
106
|
-
content. `/styled` squares off the corners as it arrives and pads the header by the top safe area.
|
|
107
|
-
- `Sheet.Portal container`: render into an element other than `<body>`. A modal sheet still makes everything else on
|
|
108
|
-
the page inert.
|
|
109
|
-
- `debug`: a function that receives the sheet's internal decision log, for testing gestures on a device.
|
|
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.
|
|
110
166
|
- `recede`: the page behind scales back like iOS while the sheet covers the screen. Mark your app's wrapper with
|
|
111
167
|
`data-ybs-background` and give it a background. While receding, `position: fixed` elements inside that wrapper are
|
|
112
|
-
positioned against it.
|
|
113
|
-
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
- `floating`: iOS 26 style, inset from the screen edges with large corners at lower detents, attaching as it reaches
|
|
121
|
-
full (tokens `--ybs-float-gap`, `--ybs-float-radius`).
|
|
122
|
-
|
|
123
|
-
```tsx
|
|
124
|
-
<BottomSheet variant="page" title="New Note" leading={<Sheet.Close>Cancel</Sheet.Close>} trailing={<button onClick={save}>Save</button>}>
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
A persistent "result" sheet that peeks at 76px and never closes by gesture:
|
|
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:
|
|
128
176
|
|
|
129
177
|
```tsx
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
}
|
|
133
202
|
```
|
|
134
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
|
+
|
|
135
207
|
### Styling
|
|
136
208
|
|
|
137
|
-
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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.
|
|
151
239
|
|
|
152
240
|
### Behaviour
|
|
153
241
|
|
|
154
242
|
- Opening, closing and the other moves the library makes (a new `detent`, the keyboard appearing, wheel steps) animate
|
|
155
|
-
in about 400ms going up and 280ms going down (Material 3 emphasized easing), and are instant with reduced motion.
|
|
156
|
-
browser's own scrolling.
|
|
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.
|
|
157
245
|
- With the content scrolled, dragging the header moves the whole sheet and keeps the content where it is, like native.
|
|
158
246
|
- A tap on content that is still scrolling only stops it, and a drag never presses what it started on.
|
|
159
247
|
- Keys scroll the content but never move the sheet down or close it; closing by keyboard is Escape. Escape that ends
|
|
@@ -162,9 +250,88 @@ A persistent "result" sheet that peeks at 76px and never closes by gesture:
|
|
|
162
250
|
- A sheet closes from a swipe, Escape, back, an outside press, a close button or `open={false}`, never from a scroll
|
|
163
251
|
nobody asked for: one that lands at closed (the browser revealing a focused field, say) is undone.
|
|
164
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
|
+
|
|
165
332
|
## Status
|
|
166
333
|
|
|
167
|
-
Early (0.x). Tested on Android in Chrome and Firefox (a gesture suite on a real phone) and in iOS Safari in the
|
|
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
|
|
168
335
|
simulator; a real iPhone is still to come. Desktop works with mouse, wheel, trackpad and keyboard.
|
|
169
336
|
|
|
170
337
|
## Found a problem?
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
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-
|
|
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
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
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
export {
|
|
2
|
+
export { p as Sheet } from "./sheet-C1fUpy71.js";
|
|
@@ -4,11 +4,12 @@ import { ComponentPropsWithRef, MouseEvent, ReactElement, ReactNode, RefObject }
|
|
|
4
4
|
* A resting position.
|
|
5
5
|
* - `'closed'` and `'full'` are always available (`'closed'` only while the sheet is dismissible).
|
|
6
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.
|
|
7
8
|
* - Any other string is a CSS length of visible sheet, e.g. `'76px'`, `'40dvh'`, `'min(320px, 50dvh)'`.
|
|
8
9
|
*/
|
|
9
|
-
type Detent = 'closed' | 'full' | number | (string & {});
|
|
10
|
+
type Detent = 'closed' | 'full' | 'header' | number | (string & {});
|
|
10
11
|
declare namespace sheet_d_exports {
|
|
11
|
-
export { Backdrop, Close, Content, ContentProps, Description, Handle, Header, InitialFocus, InteractionType, OpenChangeDetails, OpenChangeReason, Portal, PortalProps, Root, RootProps, Title, Trigger, TriggerProps, TriggerRenderProps, View, ViewProps };
|
|
12
|
+
export { Backdrop, Close, Content, ContentProps, Description, Footer, Handle, Header, InitialFocus, InteractionType, OpenChangeDetails, OpenChangeReason, Portal, PortalProps, Root, RootProps, Title, Trigger, TriggerProps, TriggerRenderProps, View, ViewProps };
|
|
12
13
|
}
|
|
13
14
|
/** Why the sheet is asking to open or close. */
|
|
14
15
|
type OpenChangeReason = 'trigger' | 'close-button' | 'escape' | 'back' | 'outside-press' | 'swipe';
|
|
@@ -21,9 +22,16 @@ interface RootProps {
|
|
|
21
22
|
defaultOpen?: boolean;
|
|
22
23
|
/** Called when the user opens or closes the sheet (not when the `open` prop changes). */
|
|
23
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;
|
|
24
31
|
/**
|
|
25
32
|
* Partial detents; `full` is always included. Default `[]`: the sheet opens straight to full (fit to short content).
|
|
26
|
-
* Each is a fraction of the available height (0.5)
|
|
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).
|
|
27
35
|
*/
|
|
28
36
|
detents?: readonly Detent[];
|
|
29
37
|
/**
|
|
@@ -116,6 +124,11 @@ interface ContentProps extends ComponentPropsWithRef<'section'> {
|
|
|
116
124
|
declare function Content({ ref, initialFocus, ...props }: ContentProps): import("react").JSX.Element;
|
|
117
125
|
/** Sticky top part of the sheet. Mouse-draggable on desktop. */
|
|
118
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;
|
|
119
132
|
/** Visual grabber. Decorative; dragging works on the whole Header. */
|
|
120
133
|
declare function Handle(props: ComponentPropsWithRef<'div'>): import("react").JSX.Element;
|
|
121
134
|
declare function Title(props: ComponentPropsWithRef<'h2'>): import("react").JSX.Element;
|
|
@@ -163,6 +163,7 @@ function createSheetController(els, options) {
|
|
|
163
163
|
const closable = () => dismissible || closing;
|
|
164
164
|
let H = 0;
|
|
165
165
|
let full = 0;
|
|
166
|
+
let sheetH = 0;
|
|
166
167
|
let stops = [];
|
|
167
168
|
const markers = /* @__PURE__ */ new Map();
|
|
168
169
|
const closedMarker = makeMarker();
|
|
@@ -200,9 +201,26 @@ function createSheetController(els, options) {
|
|
|
200
201
|
if (d === "closed") return 0;
|
|
201
202
|
if (d === "full") return full;
|
|
202
203
|
if (typeof d === "number") return H * d;
|
|
203
|
-
probe.style.height = d;
|
|
204
|
+
probe.style.height = d === "header" ? `calc(${headerBottom()}px + var(--ybs-header-detent-offset, 0px))` : d;
|
|
204
205
|
return probe.getBoundingClientRect().height;
|
|
205
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* The `'header'` detent: how far the header reaches into the sheet (its bottom edge), so the peek shows exactly
|
|
209
|
+
* the header. Measured only while the header isn't stuck (content scrolled moves a sticky header down the sheet);
|
|
210
|
+
* otherwise the last measurement is kept. Styles can add to it with --ybs-header-detent-offset (/styled's floating
|
|
211
|
+
* adds its bottom gap).
|
|
212
|
+
*/
|
|
213
|
+
let headerPx = 0;
|
|
214
|
+
function headerBottom() {
|
|
215
|
+
const header = sheet.querySelector("[data-ybs-header]");
|
|
216
|
+
if (!header) return 0;
|
|
217
|
+
if (scroller.scrollTop <= full + 1) {
|
|
218
|
+
const box = sheet.getBoundingClientRect();
|
|
219
|
+
const scale = sheet.offsetHeight > 0 ? box.height / sheet.offsetHeight : 1;
|
|
220
|
+
headerPx = (header.getBoundingClientRect().bottom - box.top) / (scale || 1);
|
|
221
|
+
}
|
|
222
|
+
return headerPx;
|
|
223
|
+
}
|
|
206
224
|
const resolve = (d) => resolveStop(d, rawTop(d), stops);
|
|
207
225
|
const pos = (d) => resolve(d).top;
|
|
208
226
|
/** The lowest resting detent that isn't closed; also where open() goes by default. */
|
|
@@ -215,7 +233,7 @@ function createSheetController(els, options) {
|
|
|
215
233
|
}
|
|
216
234
|
function layout() {
|
|
217
235
|
H = scroller.clientHeight;
|
|
218
|
-
|
|
236
|
+
sheetH = sheet.offsetHeight;
|
|
219
237
|
full = Math.min(H, sheetH);
|
|
220
238
|
const partials = requested.filter((d) => d !== "closed" && d !== "full").map((d) => ({
|
|
221
239
|
detent: d,
|
|
@@ -223,6 +241,7 @@ function createSheetController(els, options) {
|
|
|
223
241
|
}));
|
|
224
242
|
stops = buildStops(partials, full, closable());
|
|
225
243
|
root.style.setProperty("--ybs-h", `${H}px`);
|
|
244
|
+
root.style.setProperty("--ybs-header-h", `${headerBottom()}px`);
|
|
226
245
|
const bleed = root.querySelector("[data-ybs-bleed]");
|
|
227
246
|
if (bleed) bleed.style.colorScheme = getComputedStyle(sheet).colorScheme;
|
|
228
247
|
root.toggleAttribute("data-ybs-fit", sheetH < H);
|
|
@@ -239,6 +258,7 @@ function createSheetController(els, options) {
|
|
|
239
258
|
markers.set(p.detent, m);
|
|
240
259
|
}
|
|
241
260
|
m.style.top = `${p.top}px`;
|
|
261
|
+
m.toggleAttribute("data-ybs-catch", p.detent === "header");
|
|
242
262
|
m.hidden = !stops.some((s) => s.detent === p.detent);
|
|
243
263
|
}
|
|
244
264
|
}
|
|
@@ -326,6 +346,7 @@ function createSheetController(els, options) {
|
|
|
326
346
|
* stays scrolled (see detachedDrag).
|
|
327
347
|
*/
|
|
328
348
|
let scrolled = false;
|
|
349
|
+
let more = false;
|
|
329
350
|
function paint(st, drop = 0) {
|
|
330
351
|
const shown = Math.max(Math.min(st, full) - drop, 0);
|
|
331
352
|
const progress = full > 0 ? Math.min(Math.max(shown / full, 0), 1) : 0;
|
|
@@ -338,6 +359,11 @@ function createSheetController(els, options) {
|
|
|
338
359
|
scrolled = s;
|
|
339
360
|
root.toggleAttribute("data-ybs-scrolled", s);
|
|
340
361
|
}
|
|
362
|
+
const m = sheetH > H + 1 && st < scroller.scrollHeight - scroller.clientHeight - 2;
|
|
363
|
+
if (m !== more) {
|
|
364
|
+
more = m;
|
|
365
|
+
root.toggleAttribute("data-ybs-more", m);
|
|
366
|
+
}
|
|
341
367
|
}
|
|
342
368
|
function onScroll() {
|
|
343
369
|
const st = scroller.scrollTop;
|
|
@@ -980,6 +1006,7 @@ var sheet_exports = /* @__PURE__ */ __exportAll({
|
|
|
980
1006
|
Close: () => Close,
|
|
981
1007
|
Content: () => Content,
|
|
982
1008
|
Description: () => Description,
|
|
1009
|
+
Footer: () => Footer,
|
|
983
1010
|
Handle: () => Handle,
|
|
984
1011
|
Header: () => Header,
|
|
985
1012
|
Portal: () => Portal,
|
|
@@ -1030,7 +1057,7 @@ function initialFocusTarget(pref, modal, sheet) {
|
|
|
1030
1057
|
/** Full only, like iOS (`[.large()]`), Base UI and Vaul. Partial detents are opt-in. */
|
|
1031
1058
|
const DEFAULT_DETENTS = [];
|
|
1032
1059
|
function Root(props) {
|
|
1033
|
-
const { children, open: openProp, defaultOpen = false, onOpenChange, detents = DEFAULT_DETENTS, initialDetent, detent, onDetentChange, modal: modalProp = true, dismissible = true, fullscreen = false, recede = false, debug } = props;
|
|
1060
|
+
const { children, open: openProp, defaultOpen = false, onOpenChange, detents = DEFAULT_DETENTS, initialDetent, detent, onDetentChange, modal: modalProp = true, dismissible = true, fullscreen = false, recede = false, debug, onExitComplete } = props;
|
|
1034
1061
|
const [openState, setOpenState] = useState(defaultOpen);
|
|
1035
1062
|
const open = openProp ?? openState;
|
|
1036
1063
|
const [mounted, setMounted] = useState(open);
|
|
@@ -1075,6 +1102,13 @@ function Root(props) {
|
|
|
1075
1102
|
else setMounted(false);
|
|
1076
1103
|
}
|
|
1077
1104
|
}, [open, mounted]);
|
|
1105
|
+
const exitRef = useRef(onExitComplete);
|
|
1106
|
+
exitRef.current = onExitComplete;
|
|
1107
|
+
const wasMounted = useRef(mounted);
|
|
1108
|
+
useEffect(() => {
|
|
1109
|
+
if (wasMounted.current && !mounted) exitRef.current?.();
|
|
1110
|
+
wasMounted.current = mounted;
|
|
1111
|
+
}, [mounted]);
|
|
1078
1112
|
const onClosed = useCallback(() => {
|
|
1079
1113
|
debugRef.current?.(`onClosed (open=${open}, mounted=${mounted})`);
|
|
1080
1114
|
closing.current = false;
|
|
@@ -1369,6 +1403,16 @@ function Header(props) {
|
|
|
1369
1403
|
...props
|
|
1370
1404
|
});
|
|
1371
1405
|
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Sticky bottom part of the sheet, for actions. It stays at the bottom of the screen while the content scrolls, and at
|
|
1408
|
+
* partial detents too. The controller sets `data-ybs-more` on the root while content is hidden behind it.
|
|
1409
|
+
*/
|
|
1410
|
+
function Footer(props) {
|
|
1411
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1412
|
+
"data-ybs-footer": "",
|
|
1413
|
+
...props
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1372
1416
|
/** Visual grabber. Decorative; dragging works on the whole Header. */
|
|
1373
1417
|
function Handle(props) {
|
|
1374
1418
|
return /* @__PURE__ */ jsx("div", {
|
|
@@ -1401,4 +1445,4 @@ function Description(props) {
|
|
|
1401
1445
|
...props
|
|
1402
1446
|
});
|
|
1403
1447
|
}
|
|
1404
|
-
export {
|
|
1448
|
+
export { Footer as a, Portal as c, Trigger as d, View as f, Description as i, Root as l, checkCss as m, Close as n, Handle as o, sheet_exports as p, Content as r, Header as s, Backdrop as t, Title as u };
|
package/dist/styled.css
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
--ybs-handle: light-dark(rgba(60, 60, 67, 0.3), rgba(235, 235, 245, 0.3));
|
|
18
18
|
--ybs-divider: light-dark(rgba(60, 60, 67, 0.12), rgba(235, 235, 245, 0.12));
|
|
19
19
|
--ybs-radius: 14px;
|
|
20
|
+
--ybs-title-size: 17px;
|
|
20
21
|
/* Side padding of the body, matching the header. 0 for edge-to-edge lists that pad their own rows. */
|
|
21
22
|
--ybs-body-padding: 16px;
|
|
22
23
|
--ybs-backdrop: #000;
|
|
@@ -48,12 +49,16 @@
|
|
|
48
49
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
49
50
|
font-family: system-ui, -apple-system, sans-serif;
|
|
50
51
|
max-width: var(--ybs-max-width); margin-inline: auto;
|
|
52
|
+
/* The body's real side inset, for content that reaches the edges (Sheet.Edge). The sheet is a size container so
|
|
53
|
+
a full-width sheet's inset (which depends on the sheet's width) resolves the same at any depth inside it. */
|
|
54
|
+
container-type: inline-size;
|
|
55
|
+
--ybs-body-inset: var(--ybs-body-padding);
|
|
51
56
|
}
|
|
52
57
|
/* Full width: the surface spans the window; header and body keep their full width (no corner seams) but pad
|
|
53
58
|
their contents into a readable centred column. */
|
|
54
|
-
.ybs-sheet.ybs-sheet-full { max-width: none; }
|
|
59
|
+
.ybs-sheet.ybs-sheet-full { max-width: none; --ybs-body-inset: max(var(--ybs-body-padding), (100cqw - var(--ybs-content-width)) / 2); }
|
|
55
60
|
.ybs-sheet-full > .ybs-header { padding-inline: max(16px, (100% - var(--ybs-content-width)) / 2); }
|
|
56
|
-
|
|
61
|
+
|
|
57
62
|
/* A page sheet sits lower than the receded page's top edge (+10px), so a strip of that page shows above it, like iOS. */
|
|
58
63
|
[data-ybs-root]:has(.ybs-sheet-page):not([data-ybs-fullscreen]) { --ybs-inset: calc(env(safe-area-inset-top, 0px) + 22px); }
|
|
59
64
|
/* A page sheet fills the screen at full even when its content is short (no fit-content). */
|
|
@@ -62,6 +67,26 @@
|
|
|
62
67
|
.ybs-bar { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; }
|
|
63
68
|
.ybs-bar > .ybs-title { text-align: center; }
|
|
64
69
|
.ybs-leading { justify-self: start; }
|
|
70
|
+
/* Title with the ✕ beside it (closeButton without leading/trailing). */
|
|
71
|
+
.ybs-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
72
|
+
.ybs-trailing:has(.ybs-close) { display: flex; align-items: center; gap: 8px; }
|
|
73
|
+
/* The ✕: a soft filled 30px circle, with a 44px tap area. */
|
|
74
|
+
.ybs-close {
|
|
75
|
+
flex: none; display: grid; place-items: center; width: 30px; height: 30px; margin: -7px -7px -7px 0; padding: 0;
|
|
76
|
+
border: 7px solid transparent; box-sizing: content-box; background: color-mix(in srgb, var(--ybs-muted) 18%, transparent);
|
|
77
|
+
background-clip: padding-box; border-radius: 50%; color: var(--ybs-muted); cursor: pointer;
|
|
78
|
+
}
|
|
79
|
+
.ybs-close:active { background-color: color-mix(in srgb, var(--ybs-muted) 30%, transparent); }
|
|
80
|
+
.ybs-close svg { width: 12px; height: 12px; stroke: var(--ybs-muted); /* not currentColor: app button resets set color */ stroke-width: 1.8; stroke-linecap: round; fill: none; }
|
|
81
|
+
/* Screen-reader close: off screen until focused (then a small pill in the header's corner). */
|
|
82
|
+
.ybs-sr-close {
|
|
83
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%);
|
|
84
|
+
white-space: nowrap; border: 0;
|
|
85
|
+
}
|
|
86
|
+
.ybs-sr-close:focus-visible {
|
|
87
|
+
top: 8px; right: 12px; width: auto; height: auto; margin: 0; padding: 4px 12px; overflow: visible; clip-path: none;
|
|
88
|
+
border-radius: 999px; background: var(--ybs-sheet-fg); color: var(--ybs-sheet-bg); font-size: 14px;
|
|
89
|
+
}
|
|
65
90
|
.ybs-trailing { justify-self: end; }
|
|
66
91
|
/* Floating (iOS 26): at lower detents the sheet floats, inset from the sides and the bottom with large corners;
|
|
67
92
|
over the last quarter of the travel to full it attaches (the gap closes, the bottom corners slide off-screen).
|
|
@@ -75,6 +100,8 @@
|
|
|
75
100
|
clip-path: inset(0 var(--ybs-g) calc((100% - var(--ybs-visible, 0px)) * var(--ybs-float) + var(--ybs-g)) var(--ybs-g)
|
|
76
101
|
round var(--ybs-rt) var(--ybs-rt) var(--ybs-rb) var(--ybs-rb));
|
|
77
102
|
}
|
|
103
|
+
/* A 'header' detent on a floating sheet: the clip lifts the bottom edge by the gap, so the peek grows by it. */
|
|
104
|
+
[data-ybs-root]:has(.ybs-sheet-floating) { --ybs-header-detent-offset: var(--ybs-float-gap); }
|
|
78
105
|
/* Fullscreen: square corners at the top (they would show the page through), header clear of the notch. */
|
|
79
106
|
[data-ybs-fullscreen] .ybs-sheet, [data-ybs-fullscreen] .ybs-header {
|
|
80
107
|
--ybs-r: calc(var(--ybs-radius) * (1 - var(--ybs-at-top)));
|
|
@@ -85,19 +112,47 @@
|
|
|
85
112
|
background: var(--ybs-sheet-bg);
|
|
86
113
|
border-radius: var(--ybs-radius) var(--ybs-radius) 0 0;
|
|
87
114
|
padding: 6px 16px 12px;
|
|
88
|
-
|
|
115
|
+
/* The divider marks content scrolled under the header, so it only shows then (like iOS/Material bars);
|
|
116
|
+
at rest the header's bottom padding is the gap. data-ybs-scrolled is set by the controller. */
|
|
117
|
+
border-bottom: 1px solid transparent;
|
|
118
|
+
transition: border-bottom-color 150ms;
|
|
89
119
|
/* Content scrolls under this sticky header; at fractional positions (device scaling, or a covered
|
|
90
120
|
sheet's scale) a 1px sliver of it can show above. Cover it with the sheet colour. */
|
|
91
121
|
box-shadow: 0 -2px 0 0 var(--ybs-sheet-bg);
|
|
92
122
|
}
|
|
123
|
+
[data-ybs-scrolled] .ybs-header { border-bottom-color: var(--ybs-divider); }
|
|
93
124
|
.ybs-handle {
|
|
94
125
|
width: 36px; height: 5px; border-radius: 3px;
|
|
95
126
|
background: var(--ybs-handle);
|
|
96
127
|
margin: 0 auto 10px;
|
|
97
128
|
}
|
|
98
|
-
.ybs-
|
|
129
|
+
.ybs-eyebrow { display: block; margin-bottom: 2px; font-size: 13px; font-weight: 500; color: var(--ybs-muted); }
|
|
130
|
+
.ybs-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
131
|
+
/* No title (a sheet named by aria-label): the header is just the grabber, without the space a title would need. */
|
|
132
|
+
.ybs-header:has(> .ybs-handle:last-child), .ybs-header:has(> .ybs-handle + .ybs-sr-close:last-child) { padding-bottom: 0; }
|
|
133
|
+
.ybs-title { margin: 0; font-size: var(--ybs-title-size); font-weight: 600; line-height: 1.3; }
|
|
99
134
|
.ybs-description { margin: 4px 0 0; font-size: 14px; color: var(--ybs-muted); }
|
|
100
|
-
.ybs-body { padding: 0 var(--ybs-body-
|
|
135
|
+
.ybs-body { padding: 0 var(--ybs-body-inset) 12px; }
|
|
136
|
+
/* Sheet.Edge: reaches the sheet's edges and pads its contents back in line with the body (a photo strip that
|
|
137
|
+
scrolls sideways, a full-width tappable row, a separator). */
|
|
138
|
+
.ybs-edge { margin-inline: calc(-1 * var(--ybs-body-inset)); padding-inline: var(--ybs-body-inset); }
|
|
139
|
+
/* Footer: actions pinned to the screen bottom. It takes the safe area from the sheet, and its line (like the
|
|
140
|
+
header's) shows only while content is hidden behind it (data-ybs-more, set by the controller). */
|
|
141
|
+
.ybs-footer {
|
|
142
|
+
background: var(--ybs-sheet-bg);
|
|
143
|
+
padding: 12px var(--ybs-body-inset) calc(12px + env(safe-area-inset-bottom, 0px));
|
|
144
|
+
border-top: 1px solid transparent;
|
|
145
|
+
transition: border-top-color 150ms;
|
|
146
|
+
display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap;
|
|
147
|
+
}
|
|
148
|
+
[data-ybs-more] .ybs-footer { border-top-color: var(--ybs-divider); }
|
|
149
|
+
.ybs-sheet:has(> .ybs-footer) { padding-bottom: 0; }
|
|
150
|
+
/* Floating: the clip lifts the bottom edge by the gap at lower detents; the footer stays above it. */
|
|
151
|
+
.ybs-sheet-floating > .ybs-footer { bottom: var(--ybs-g); }
|
|
152
|
+
/* Floating: the visible edge is the clip, a gap in from the sheet's box, so the side space adds the gap (fixed, not
|
|
153
|
+
following the float: padding that changed while dragging would reflow every frame). */
|
|
154
|
+
.ybs-sheet.ybs-sheet-floating:not(.ybs-sheet-full) { --ybs-body-inset: calc(var(--ybs-body-padding) + var(--ybs-float-gap)); }
|
|
155
|
+
.ybs-sheet-floating:not(.ybs-sheet-full) > .ybs-header { padding-inline: calc(16px + var(--ybs-float-gap)); }
|
|
101
156
|
/* The page behind recedes (Sheet.Root recede + data-ybs-background on the app wrapper), like iOS: it scales back
|
|
102
157
|
from the top of the screen, drops below the status bar and rounds its top corners over black. The wrapper needs
|
|
103
158
|
its own background (html and body turn black). */
|
package/dist/styled.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
|
|
2
|
-
import { a as OpenChangeDetails, c as PortalProps, d as Trigger, f as TriggerProps, g as Detent, i as InteractionType, l as Root, m as ViewProps, n as ContentProps, o as OpenChangeReason, p as TriggerRenderProps, r as InitialFocus, s as Portal, t as Close, u as RootProps } from "./sheet-
|
|
2
|
+
import { a as OpenChangeDetails, c as PortalProps, d as Trigger, f as TriggerProps, g as Detent, i as InteractionType, l as Root, m as ViewProps, n as ContentProps, o as OpenChangeReason, p as TriggerRenderProps, r as InitialFocus, s as Portal, t as Close, u as RootProps } from "./sheet-5lG9EBB5.js";
|
|
3
3
|
import { ComponentPropsWithRef, ReactElement, ReactNode } from "react";
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Styled parts: the pieces `BottomSheet` is built from, with the preset look (same API as the headless `Sheet`).
|
|
6
|
+
* Prefer `BottomSheet`; use these only to arrange a sheet yourself.
|
|
7
|
+
*/
|
|
8
|
+
export declare const StyledSheet: {
|
|
6
9
|
Root: typeof Root;
|
|
7
10
|
Trigger: typeof Trigger;
|
|
8
11
|
Close: typeof Close;
|
|
@@ -14,15 +17,24 @@ export declare const Sheet: {
|
|
|
14
17
|
Handle: (p: import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => import("react").JSX.Element;
|
|
15
18
|
Title: (p: import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) => import("react").JSX.Element;
|
|
16
19
|
Description: (p: import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>) => import("react").JSX.Element;
|
|
20
|
+
Footer: (p: import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => import("react").JSX.Element;
|
|
21
|
+
/** The padded content area, after `Header`: the same side space as the header, so text lines up with the title. */
|
|
22
|
+
Body: ({ className, ...p }: ComponentPropsWithRef<'div'>) => import("react").JSX.Element;
|
|
23
|
+
/**
|
|
24
|
+
* Content that reaches the sheet's edges, inside the body: a sideways photo strip, a full-width tappable row, a
|
|
25
|
+
* separator. Its contents stay lined up with the rest of the body. The inset is `--ybs-body-inset`.
|
|
26
|
+
*/
|
|
27
|
+
Edge: ({ className, ...p }: ComponentPropsWithRef<'div'>) => import("react").JSX.Element;
|
|
17
28
|
};
|
|
18
|
-
|
|
29
|
+
interface BottomSheetBaseProps extends Omit<RootProps, 'children'> {
|
|
19
30
|
/**
|
|
20
31
|
* What opens the sheet. Content (text, elements) is placed inside our trigger button. A function renders your own
|
|
21
32
|
* button instead: `trigger={(props) => <Button variant="paper" {...props}>Open</Button>}` (it receives `type`,
|
|
22
33
|
* `onClick` and the ARIA state; put className, disabled, aria-label on your button).
|
|
23
34
|
*/
|
|
24
35
|
trigger?: ReactNode | ((props: TriggerRenderProps) => ReactElement);
|
|
25
|
-
title
|
|
36
|
+
/** A small label above the title (a section, a category). Part of the title, so screen readers read both. */
|
|
37
|
+
eyebrow?: ReactNode;
|
|
26
38
|
description?: ReactNode;
|
|
27
39
|
/**
|
|
28
40
|
* - `'sheet'` (default): detents as given, a card-width sheet.
|
|
@@ -42,11 +54,25 @@ export interface BottomSheetProps extends Omit<RootProps, 'children'> {
|
|
|
42
54
|
* reaches full. Tokens: --ybs-float-gap (8px), --ybs-float-radius (32px).
|
|
43
55
|
*/
|
|
44
56
|
floating?: boolean;
|
|
45
|
-
/** Header bar slots, iOS navigation-bar style: e.g. `leading={<
|
|
57
|
+
/** Header bar slots, iOS navigation-bar style: e.g. `leading={<BottomSheet.Close>Cancel</BottomSheet.Close>}`. The title centres between them. */
|
|
46
58
|
leading?: ReactNode;
|
|
47
59
|
trailing?: ReactNode;
|
|
48
60
|
/** Extra content placed in the sticky header under the title (e.g. a search field). */
|
|
49
61
|
headerExtra?: ReactNode;
|
|
62
|
+
/**
|
|
63
|
+
* Actions pinned to the bottom of the screen (Cancel / Confirm, Show results). They stay reachable while a long
|
|
64
|
+
* sheet scrolls, reach the sheet's edges, and take the home-bar safe area. A line appears above them only while
|
|
65
|
+
* content is hidden behind them.
|
|
66
|
+
*/
|
|
67
|
+
footer?: ReactNode;
|
|
68
|
+
/**
|
|
69
|
+
* A visible ✕ button in the header's top right that closes the sheet. Off by default. Without it, a dismissible
|
|
70
|
+
* sheet still has a close button that only screen readers announce (it shows when focused by keyboard), because
|
|
71
|
+
* swipes and the dimmed area aren't usable with VoiceOver.
|
|
72
|
+
*/
|
|
73
|
+
closeButton?: boolean;
|
|
74
|
+
/** Accessible name of the close button (visible ✕ or screen-reader only). Default `'Close'`. */
|
|
75
|
+
closeLabel?: string;
|
|
50
76
|
/**
|
|
51
77
|
* Where focus goes when the sheet opens, like Base UI's: an element ref, `false`, `true` (the sheet), or a function
|
|
52
78
|
* of how it was opened, e.g. `(type) => type !== 'touch' && saveRef.current`. Default: the sheet for a modal sheet,
|
|
@@ -57,9 +83,24 @@ export interface BottomSheetProps extends Omit<RootProps, 'children'> {
|
|
|
57
83
|
className?: string;
|
|
58
84
|
contentProps?: ComponentPropsWithRef<'section'>;
|
|
59
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Every sheet needs a name for screen readers ("Share photo, dialog"): a visible `title`, or an `aria-label` for a
|
|
88
|
+
* sheet without one (an action list, a photo viewer). TypeScript refuses a sheet with neither.
|
|
89
|
+
*/
|
|
90
|
+
export type BottomSheetProps = BottomSheetBaseProps & ({
|
|
91
|
+
title: NonNullable<ReactNode>;
|
|
92
|
+
'aria-label'?: string;
|
|
93
|
+
} | {
|
|
94
|
+
title?: undefined; /** Names a sheet that has no visible title. */
|
|
95
|
+
'aria-label': string;
|
|
96
|
+
});
|
|
60
97
|
/**
|
|
61
98
|
* The simple path:
|
|
62
99
|
* <BottomSheet trigger="Open" title="Settings">…</BottomSheet>
|
|
63
100
|
*/
|
|
64
|
-
export declare function BottomSheet({ trigger, title, description, variant, floating, width, leading, trailing, headerExtra, initialFocus, children, className, contentProps, ...rootProps }: BottomSheetProps): import("react").JSX.Element;
|
|
101
|
+
export declare function BottomSheet({ trigger, title, eyebrow, description, variant, floating, width, leading, trailing, headerExtra, footer, closeButton, closeLabel, initialFocus, children, className, contentProps, 'aria-label': ariaLabel, ...rootProps }: BottomSheetProps): import("react").JSX.Element;
|
|
102
|
+
export declare namespace BottomSheet {
|
|
103
|
+
var Edge: ({ className, ...p }: ComponentPropsWithRef<'div'>) => import("react").JSX.Element;
|
|
104
|
+
var Close: typeof Close;
|
|
105
|
+
}
|
|
65
106
|
export type { ContentProps, Detent, InitialFocus, InteractionType, OpenChangeDetails, OpenChangeReason, PortalProps, RootProps, TriggerProps, TriggerRenderProps, ViewProps };
|
package/dist/styled.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { a as
|
|
2
|
+
import { a as Footer, c as Portal, d as Trigger, f as View, i as Description, l as Root, m as checkCss, n as Close, o as Handle, r as Content, s as Header, t as Backdrop, u as Title } from "./sheet-C1fUpy71.js";
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
// src/styled/index.tsx
|
|
@@ -10,8 +10,11 @@ function withClass(Comp, cls) {
|
|
|
10
10
|
className: cx(cls, p.className)
|
|
11
11
|
});
|
|
12
12
|
}
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Styled parts: the pieces `BottomSheet` is built from, with the preset look (same API as the headless `Sheet`).
|
|
15
|
+
* Prefer `BottomSheet`; use these only to arrange a sheet yourself.
|
|
16
|
+
*/
|
|
17
|
+
const StyledSheet = {
|
|
15
18
|
Root,
|
|
16
19
|
Trigger,
|
|
17
20
|
Close,
|
|
@@ -29,50 +32,94 @@ const Sheet = {
|
|
|
29
32
|
Header: withClass(Header, "ybs-header"),
|
|
30
33
|
Handle: withClass(Handle, "ybs-handle"),
|
|
31
34
|
Title: withClass(Title, "ybs-title"),
|
|
32
|
-
Description: withClass(Description, "ybs-description")
|
|
35
|
+
Description: withClass(Description, "ybs-description"),
|
|
36
|
+
Footer: withClass(Footer, "ybs-footer"),
|
|
37
|
+
/** The padded content area, after `Header`: the same side space as the header, so text lines up with the title. */
|
|
38
|
+
Body: ({ className, ...p }) => /* @__PURE__ */ jsx("div", {
|
|
39
|
+
...p,
|
|
40
|
+
className: cx("ybs-body", className)
|
|
41
|
+
}),
|
|
42
|
+
/**
|
|
43
|
+
* Content that reaches the sheet's edges, inside the body: a sideways photo strip, a full-width tappable row, a
|
|
44
|
+
* separator. Its contents stay lined up with the rest of the body. The inset is `--ybs-body-inset`.
|
|
45
|
+
*/
|
|
46
|
+
Edge: ({ className, ...p }) => /* @__PURE__ */ jsx("div", {
|
|
47
|
+
...p,
|
|
48
|
+
className: cx("ybs-edge", className)
|
|
49
|
+
})
|
|
33
50
|
};
|
|
34
51
|
/**
|
|
35
52
|
* The simple path:
|
|
36
53
|
* <BottomSheet trigger="Open" title="Settings">…</BottomSheet>
|
|
37
54
|
*/
|
|
38
|
-
function BottomSheet({ trigger, title, description, variant = "sheet", floating = false, width = variant === "page" ? "full" : "card", leading, trailing, headerExtra, initialFocus, children, className, contentProps, ...rootProps }) {
|
|
55
|
+
function BottomSheet({ trigger, title, eyebrow, description, variant = "sheet", floating = false, width = variant === "page" ? "full" : "card", leading, trailing, headerExtra, footer, closeButton = false, closeLabel = "Close", initialFocus, children, className, contentProps, "aria-label": ariaLabel, ...rootProps }) {
|
|
39
56
|
const root = variant === "page" ? {
|
|
40
57
|
recede: true,
|
|
41
58
|
...rootProps
|
|
42
59
|
} : rootProps;
|
|
43
60
|
const bar = leading != null || trailing != null;
|
|
44
|
-
|
|
61
|
+
const heading = title == null ? null : /* @__PURE__ */ jsxs(StyledSheet.Title, { children: [eyebrow != null && /* @__PURE__ */ jsxs("span", {
|
|
62
|
+
className: "ybs-eyebrow",
|
|
63
|
+
children: [eyebrow, /* @__PURE__ */ jsx("span", {
|
|
64
|
+
className: "ybs-sr-only",
|
|
65
|
+
children: ", "
|
|
66
|
+
})]
|
|
67
|
+
}), title] });
|
|
68
|
+
const close = closeButton ? /* @__PURE__ */ jsx(StyledSheet.Close, {
|
|
69
|
+
className: "ybs-close",
|
|
70
|
+
"aria-label": closeLabel,
|
|
71
|
+
children: /* @__PURE__ */ jsx("svg", {
|
|
72
|
+
viewBox: "0 0 12 12",
|
|
73
|
+
"aria-hidden": "true",
|
|
74
|
+
children: /* @__PURE__ */ jsx("path", { d: "M2 2l8 8M10 2l-8 8" })
|
|
75
|
+
})
|
|
76
|
+
}) : null;
|
|
77
|
+
const srClose = !closeButton && rootProps.dismissible !== false && /* @__PURE__ */ jsx(StyledSheet.Close, {
|
|
78
|
+
className: "ybs-sr-close",
|
|
79
|
+
children: closeLabel
|
|
80
|
+
});
|
|
81
|
+
return /* @__PURE__ */ jsxs(StyledSheet.Root, {
|
|
45
82
|
...root,
|
|
46
|
-
children: [typeof trigger === "function" ? /* @__PURE__ */ jsx(
|
|
83
|
+
children: [typeof trigger === "function" ? /* @__PURE__ */ jsx(StyledSheet.Trigger, { render: trigger }) : trigger != null && /* @__PURE__ */ jsx(StyledSheet.Trigger, {
|
|
47
84
|
className: "ybs-trigger",
|
|
48
85
|
children: trigger
|
|
49
|
-
}), /* @__PURE__ */ jsxs(
|
|
86
|
+
}), /* @__PURE__ */ jsxs(StyledSheet.Portal, { children: [root.modal !== false && /* @__PURE__ */ jsx(StyledSheet.Backdrop, {}), /* @__PURE__ */ jsx(StyledSheet.View, { children: /* @__PURE__ */ jsxs(StyledSheet.Content, {
|
|
50
87
|
...contentProps,
|
|
88
|
+
"aria-label": ariaLabel ?? contentProps?.["aria-label"],
|
|
51
89
|
initialFocus,
|
|
52
90
|
className: cx(width === "full" && "ybs-sheet-full", variant === "page" && "ybs-sheet-page", floating && "ybs-sheet-floating", className, contentProps?.className),
|
|
53
|
-
children: [
|
|
54
|
-
/* @__PURE__ */
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
91
|
+
children: [
|
|
92
|
+
/* @__PURE__ */ jsxs(StyledSheet.Header, { children: [
|
|
93
|
+
/* @__PURE__ */ jsx(StyledSheet.Handle, {}),
|
|
94
|
+
bar ? /* @__PURE__ */ jsxs("div", {
|
|
95
|
+
className: "ybs-bar",
|
|
96
|
+
children: [
|
|
97
|
+
/* @__PURE__ */ jsx("div", {
|
|
98
|
+
className: "ybs-leading",
|
|
99
|
+
children: leading
|
|
100
|
+
}),
|
|
101
|
+
heading ?? /* @__PURE__ */ jsx("span", {}),
|
|
102
|
+
/* @__PURE__ */ jsxs("div", {
|
|
103
|
+
className: "ybs-trailing",
|
|
104
|
+
children: [trailing, close]
|
|
105
|
+
})
|
|
106
|
+
]
|
|
107
|
+
}) : close ? /* @__PURE__ */ jsxs("div", {
|
|
108
|
+
className: "ybs-title-row",
|
|
109
|
+
children: [heading ?? /* @__PURE__ */ jsx("span", {}), close]
|
|
110
|
+
}) : heading,
|
|
111
|
+
description != null && /* @__PURE__ */ jsx(StyledSheet.Description, { children: description }),
|
|
112
|
+
headerExtra,
|
|
113
|
+
srClose
|
|
114
|
+
] }),
|
|
115
|
+
/* @__PURE__ */ jsx(StyledSheet.Body, { children }),
|
|
116
|
+
footer != null && /* @__PURE__ */ jsx(StyledSheet.Footer, { children: footer })
|
|
117
|
+
]
|
|
75
118
|
}) })] })]
|
|
76
119
|
});
|
|
77
120
|
}
|
|
78
|
-
|
|
121
|
+
/** Content that reaches the sheet's edges (see `StyledSheet.Edge`), for use inside `<BottomSheet>`. */
|
|
122
|
+
BottomSheet.Edge = StyledSheet.Edge;
|
|
123
|
+
/** Closes the sheet it's in, e.g. a Cancel button in `leading` or `footer`. */
|
|
124
|
+
BottomSheet.Close = StyledSheet.Close;
|
|
125
|
+
export { BottomSheet, StyledSheet };
|
package/dist/styles.css
CHANGED
|
@@ -60,12 +60,20 @@
|
|
|
60
60
|
position: absolute; left: 0; width: 1px; height: 1px;
|
|
61
61
|
scroll-snap-align: start; pointer-events: none;
|
|
62
62
|
}
|
|
63
|
+
/* The 'header' peek catches flings (like Material's collapsed state); other partial detents let a fling run past. */
|
|
64
|
+
[data-ybs-marker][data-ybs-catch] { scroll-snap-stop: always; }
|
|
63
65
|
/* scroll-snap-stop: a fling may not skip the full position, in either direction. This is the native
|
|
64
66
|
way to stop momentum at the content top / full detent; the JS guard is only a fallback. (Firefox
|
|
65
67
|
Android: the JS overflow toggle made the next touch get swallowed as a "stop the fling" tap.) */
|
|
66
68
|
[data-ybs-sheet] { position: relative; scroll-snap-align: start; scroll-snap-stop: always; outline: none; }
|
|
67
69
|
[data-ybs-root][data-ybs-fit] [data-ybs-sheet] { scroll-snap-align: end; }
|
|
68
70
|
[data-ybs-header] { position: sticky; top: 0; z-index: 1; }
|
|
71
|
+
[data-ybs-footer] {
|
|
72
|
+
position: sticky; bottom: 0; z-index: 1;
|
|
73
|
+
/* At a peek lower than header + footer, pinning the footer to the screen bottom would cover the header. Push it
|
|
74
|
+
down so it sits under the header instead (off screen at a header peek), and let it pin once there's room. */
|
|
75
|
+
translate: 0 max(0px, var(--ybs-header-h, 0px) + var(--ybs-header-detent-offset, 0px) + 100% - var(--ybs-visible, 100vh));
|
|
76
|
+
}
|
|
69
77
|
/* Content scrolled: a drag on the header moves the whole sheet (JS, "detached drag"), like native, instead of
|
|
70
78
|
scrolling the content back first. */
|
|
71
79
|
[data-ybs-root][data-ybs-scrolled] [data-ybs-drag] { touch-action: none; }
|
|
@@ -83,7 +91,7 @@
|
|
|
83
91
|
}
|
|
84
92
|
[data-ybs-root][data-ybs-modeless] [data-ybs-sheet] > *::-webkit-scrollbar { display: none; }
|
|
85
93
|
/* 1px of horizontal overflow, measured from the padding box so side padding can't absorb it. */
|
|
86
|
-
[data-ybs-root][data-ybs-modeless] [data-ybs-sheet] > *:not([data-ybs-header]) { position: relative; }
|
|
94
|
+
[data-ybs-root][data-ybs-modeless] [data-ybs-sheet] > *:not([data-ybs-header], [data-ybs-footer]) { position: relative; }
|
|
87
95
|
[data-ybs-root][data-ybs-modeless] [data-ybs-sheet] > *::after {
|
|
88
96
|
content: ''; position: absolute; top: 0; left: 0; width: calc(100% + 1px); height: 1px; pointer-events: none;
|
|
89
97
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yourrealm/bottom-sheet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Native-feeling bottom sheet for the web. One native scroller for sheet travel and content.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"files": [
|
|
28
28
|
"dist",
|
|
29
29
|
"README.md",
|
|
30
|
-
"LICENSE"
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"CHANGELOG.md"
|
|
31
32
|
],
|
|
32
33
|
"exports": {
|
|
33
34
|
".": {
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
"build": "tsdown && node scripts/build-css.mjs",
|
|
60
61
|
"test": "vitest run",
|
|
61
62
|
"typecheck": "tsc -p .",
|
|
62
|
-
"pack:check": "node scripts/prepack.mjs && pnpm build && pnpm pack; rm -f README.md LICENSE",
|
|
63
|
-
"release": "node scripts/prepack.mjs && pnpm build && pnpm publish --access public; rm -f README.md LICENSE"
|
|
63
|
+
"pack:check": "node scripts/prepack.mjs && pnpm build && pnpm pack; rm -f README.md LICENSE CHANGELOG.md",
|
|
64
|
+
"release": "node scripts/prepack.mjs && pnpm build && pnpm publish --access public; rm -f README.md LICENSE CHANGELOG.md"
|
|
64
65
|
}
|
|
65
66
|
}
|