@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 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 trigger="Filters" title="Filters" detents={[0.5]}>
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
- Headless parts, for your own look:
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 { Sheet } from '@yourrealm/bottom-sheet'
73
+ import { BottomSheet } from '@yourrealm/bottom-sheet/styled'
55
74
 
56
- <Sheet.Root detents={[0.4]} onDetentChange={(d) => console.log(d)}>
57
- <Sheet.Trigger>Up next</Sheet.Trigger>
58
- <Sheet.Portal>
59
- <Sheet.Backdrop className="backdrop" />
60
- <Sheet.View>
61
- <Sheet.Content className="sheet">
62
- <Sheet.Header className="header">
63
- <Sheet.Handle />
64
- <Sheet.Title>Up next</Sheet.Title>
65
- <Sheet.Close>Done</Sheet.Close>
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
- ### Options
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
- - `detents`: partial resting heights, as fractions of the height available to the sheet (`0.5`) or CSS lengths of visible sheet (`'76px'`,
77
- `'40dvh'`). Full height is always included, and is the content's own height when the content is short. Default `[]`:
78
- the sheet opens straight to full, like iOS. It opens at the smallest partial detent when you give some, or at
79
- `initialDetent`.
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
- - `detent` / `onDetentChange`: the detent the sheet rests at. Pass `detent` to control it (changing it moves the
84
- sheet); `onDetentChange` reports where it came to rest, never `'closed'` (closing is `onOpenChange(false)`), so
85
- `detent={d} onDetentChange={setD}` round-trips.
86
- - `trigger` (`/styled`): text or elements go inside our trigger button; a function renders your own button (a
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 `Sheet.Header`, and render popovers
94
- in a portal rather than inside the sheet's children.
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 `<Sheet.Close>` still close it.
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 short
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. Page sheets (`variant="page"`) recede by default.
113
- - `/styled` only:
114
- - `title`, `description` (headless: `Sheet.Title`, `Sheet.Description`, which name and describe the dialog; without
115
- a `Sheet.Title`, give `Sheet.Content` an `aria-label`), and `leading` / `trailing` header slots (Cancel / title / Save, title centred), plus
116
- `headerExtra` for things under the title (a search field).
117
- - `variant="page"`: an iOS page sheet for a task: full width, the page behind recedes, and the sheet fills the
118
- screen at full. Leave `detents` out and it opens straight to full.
119
- - `width`: `'card'` (at most 640px, centred) or `'full'` (spans the window, content in a centred 960px column).
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
- <BottomSheet open={solved} modal={false} dismissible={false} detents={['76px']} title="Solved">
131
- …
132
- </BottomSheet>
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
- - `/styled` light and dark follow your app's `color-scheme` (inherited from `<html>`), so a theme switch that sets it
138
- gets a matching sheet. Set `html { color-scheme: light dark }` to follow the phone's setting; with no `color-scheme`
139
- at all the sheet stays light. All colours are `--ybs-*` tokens.
140
- - One sheet in a fixed theme: set `color-scheme` on it, e.g. `className="[color-scheme:dark]"` or
141
- `contentProps={{ style: { colorScheme: 'dark' } }}` (headless: on `Sheet.Content`).
142
- - The `/styled` body gets 16px side padding like the header (`--ybs-body-padding`; set it to `0` for edge-to-edge lists).
143
- - Headless `Sheet.View` passes its props (`className`, `style`, `ref`, `data-*`) to the scroller, the one scroll container.
144
- - CSS variables on `[data-ybs-root]`: `--ybs-progress` (0 to 1, closed to full) for your own backdrop or effects,
145
- `--ybs-visible` (px of the sheet on screen), and `--ybs-sheet-bg` (the colour that continues behind the keyboard).
146
- While the content is scrolled the root has `data-ybs-scrolled`.
147
- - Headless: content scrolling under a sticky `Sheet.Header` can show a 1px sliver above it; cover it with a
148
- `box-shadow: 0 -2px 0 0 <sheet colour>` on the header (only while `data-ybs-scrolled` if your sheet has a border).
149
- - Don't put `touch-action` classes on `Sheet.Header` (Tailwind's utilities layer beats ours): the library turns it off
150
- there while the content is scrolled, so that dragging the header moves the whole sheet.
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. Drags and flings are the
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-BOTt16RW.js";
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 { f as Sheet } from "./sheet-BgvWfCR8.js";
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) or a CSS length of visible sheet ('76px', '40dvh').
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
- const sheetH = sheet.offsetHeight;
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 { Handle as a, Root as c, View as d, sheet_exports as f, Description as i, Title as l, Close as n, Header as o, checkCss as p, Content as r, Portal as s, Backdrop as t, Trigger as u };
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
- .ybs-sheet-full > .ybs-body { padding-inline: max(var(--ybs-body-padding), (100% - var(--ybs-content-width)) / 2); }
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
- border-bottom: 1px solid var(--ybs-divider);
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-title { margin: 0; font-size: 17px; font-weight: 600; line-height: 1.3; }
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-padding) 12px; }
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-BOTt16RW.js";
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
- /** Styled parts: same API as the headless `Sheet`, with the preset look. */
5
- export declare const Sheet: {
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
- export interface BottomSheetProps extends Omit<RootProps, 'children'> {
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: ReactNode;
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={<Sheet.Close>Cancel</Sheet.Close>}`. The title centres between them. */
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 Handle, c as Root, d as View, i as Description, l as Title, n as Close, o as Header, p as checkCss, r as Content, s as Portal, t as Backdrop, u as Trigger } from "./sheet-BgvWfCR8.js";
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
- /** Styled parts: same API as the headless `Sheet`, with the preset look. */
14
- const Sheet = {
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
- return /* @__PURE__ */ jsxs(Sheet.Root, {
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(Sheet.Trigger, { render: trigger }) : trigger != null && /* @__PURE__ */ jsx(Sheet.Trigger, {
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(Sheet.Portal, { children: [root.modal !== false && /* @__PURE__ */ jsx(Sheet.Backdrop, {}), /* @__PURE__ */ jsx(Sheet.View, { children: /* @__PURE__ */ jsxs(Sheet.Content, {
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: [/* @__PURE__ */ jsxs(Sheet.Header, { children: [
54
- /* @__PURE__ */ jsx(Sheet.Handle, {}),
55
- bar ? /* @__PURE__ */ jsxs("div", {
56
- className: "ybs-bar",
57
- children: [
58
- /* @__PURE__ */ jsx("div", {
59
- className: "ybs-leading",
60
- children: leading
61
- }),
62
- /* @__PURE__ */ jsx(Sheet.Title, { children: title }),
63
- /* @__PURE__ */ jsx("div", {
64
- className: "ybs-trailing",
65
- children: trailing
66
- })
67
- ]
68
- }) : /* @__PURE__ */ jsx(Sheet.Title, { children: title }),
69
- description != null && /* @__PURE__ */ jsx(Sheet.Description, { children: description }),
70
- headerExtra
71
- ] }), /* @__PURE__ */ jsx("div", {
72
- className: "ybs-body",
73
- children
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
- export { BottomSheet, Sheet };
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.1.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
  }