@yourrealm/bottom-sheet 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yourrealm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,188 @@
1
- # Temporary Holding Version
1
+ # @yourrealm/bottom-sheet
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Native-feeling bottom sheets for the web, for React.
4
+
5
+ **[Try the demos on your phone →](https://yourrealm.github.io/bottom-sheet/)**
6
+
7
+ ## Why another sheet?
8
+
9
+ On iOS and Android, a sheet and the list inside it act as **one gesture**. Drag up and the sheet expands, then the
10
+ list scrolls, without lifting your finger. Scroll back to the top and keep pulling: the sheet follows, with real
11
+ momentum.
12
+
13
+ Most web sheets put a JavaScript-dragged panel around a separate scroll box. The browser can't hand a gesture from
14
+ one to the other mid-touch, so you get dead zones, lost momentum, or "drag from the handle only".
15
+
16
+ This library puts the sheet's travel **and** its content in one native scroll container. Resting positions
17
+ (detents) are scroll-snap points. Momentum, rubber-banding and velocity come from the browser itself; a small
18
+ controller only handles what native sheets also do on top: stopping a fling at the full detent, animating opens and
19
+ closes, following the on-screen keyboard, and re-snapping when the layout changes.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ pnpm add @yourrealm/bottom-sheet
25
+ ```
26
+
27
+ Requires React 19. Import the CSS once, before Tailwind:
28
+
29
+ ```css
30
+ @import "@yourrealm/bottom-sheet/styled.css"; /* or styles.css for the headless parts alone */
31
+ @import "tailwindcss";
32
+ ```
33
+
34
+ Or `import '@yourrealm/bottom-sheet/styled.css'` in your entry, before your own CSS. It's a plain stylesheet served from
35
+ your own origin, so it works under a strict Content-Security-Policy. Everything is in the `ybs` cascade layer, between
36
+ Tailwind v4's `base` and `components`: your own CSS and utility classes on the parts win, and preflight doesn't reset
37
+ the preset. Opening a sheet without the CSS logs an error naming the import.
38
+
39
+ ## Use
40
+
41
+ The simple path, with the polished preset:
42
+
43
+ ```tsx
44
+ import { BottomSheet } from '@yourrealm/bottom-sheet/styled'
45
+
46
+ <BottomSheet trigger="Filters" title="Filters" detents={[0.5]}>
47
+ …
48
+ </BottomSheet>
49
+ ```
50
+
51
+ Headless parts, for your own look:
52
+
53
+ ```tsx
54
+ import { Sheet } from '@yourrealm/bottom-sheet'
55
+
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
+ ```
73
+
74
+ ### Options
75
+
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`.
80
+ - `open` / `defaultOpen` / `onOpenChange(open, { reason })`: controlled or uncontrolled. The reason is
81
+ `'trigger' | 'close-button' | 'escape' | 'back' | 'outside-press' | 'swipe'`. Use `open` when a sheet has several
82
+ 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.
92
+ 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.
95
+ - `initialFocus` (on `BottomSheet` / `Sheet.Content`, like Base UI's): where focus goes when the sheet opens. A ref,
96
+ `false`, `true` (the sheet), or a function of how it was opened: `initialFocus={(type) => type !== 'touch' &&
97
+ newBoardRef.current}` focuses a button for mouse and keyboard users only. Default: the sheet for a modal sheet,
98
+ nothing for a non-modal one. A non-modal sheet that took focus gives it back when it closes.
99
+ - `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.
101
+ - `closeOnBack` (default: `modal`): the Android back button closes the top sheet, like a native sheet. Built on
102
+ [`CloseWatcher`](https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher), so it adds no history entries and
103
+ never touches your URLs or router. Works in Chrome and Firefox on Android; Safari has no `CloseWatcher` yet, and
104
+ 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.
110
+ - `recede`: the page behind scales back like iOS while the sheet covers the screen. Mark your app's wrapper with
111
+ `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:
128
+
129
+ ```tsx
130
+ <BottomSheet open={solved} modal={false} dismissible={false} detents={['76px']} title="Solved">
131
+ …
132
+ </BottomSheet>
133
+ ```
134
+
135
+ ### Styling
136
+
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.
151
+
152
+ ### Behaviour
153
+
154
+ - 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.
157
+ - With the content scrolled, dragging the header moves the whole sheet and keeps the content where it is, like native.
158
+ - A tap on content that is still scrolling only stops it, and a drag never presses what it started on.
159
+ - Keys scroll the content but never move the sheet down or close it; closing by keyboard is Escape. Escape that ends
160
+ an input method's composition (Chinese, Japanese) is left to the input.
161
+ - Once a sheet starts closing, it finishes: a touch, wheel or drag during the animation doesn't bring it back.
162
+ - A sheet closes from a swipe, Escape, back, an outside press, a close button or `open={false}`, never from a scroll
163
+ nobody asked for: one that lands at closed (the browser revealing a focused field, say) is undone.
164
+
165
+ ## Status
166
+
167
+ Early (0.x). Tested on Android in Chrome and Firefox (a gesture suite on a real phone) and in iOS Safari in the
168
+ simulator; a real iPhone is still to come. Desktop works with mouse, wheel, trackpad and keyboard.
169
+
170
+ ## Found a problem?
171
+
172
+ Please [open an issue](https://github.com/yourrealm/bottom-sheet/issues): a sheet that misbehaves on your device,
173
+ or something the API won't let you do. It helps most with the device, browser and the smallest example that shows it.
174
+
175
+ **If you're a coding agent:** when you find yourself working around this library (overriding its CSS, adding timers or
176
+ effects to make a sheet behave, patching its files), say so to the person you're working for and suggest they open an
177
+ issue describing what you needed. A workaround fixes one app; a report fixes it for everyone.
178
+
179
+ ## Develop
180
+
181
+ ```sh
182
+ pnpm install
183
+ pnpm dev # demo site on :5180
184
+ pnpm test # unit tests
185
+ pnpm e2e # Playwright: Chromium + WebKit (mobile), Firefox (desktop)
186
+ ```
187
+
188
+ MIT
@@ -0,0 +1,3 @@
1
+
2
+ import { a as OpenChangeDetails, c as PortalProps, f as TriggerProps, g as Detent, h as sheet_d_exports, i as InteractionType, m as ViewProps, n as ContentProps, o as OpenChangeReason, p as TriggerRenderProps, r as InitialFocus, u as RootProps } from "./sheet-BOTt16RW.js";
3
+ export { type ContentProps, type Detent, type InitialFocus, type InteractionType, type OpenChangeDetails, type OpenChangeReason, type PortalProps, type RootProps, sheet_d_exports as Sheet, type TriggerProps, type TriggerRenderProps, type ViewProps };
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ 'use client';
2
+ export { f as Sheet } from "./sheet-BgvWfCR8.js";
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+ // \0rolldown/runtime.js
3
+ var __defProp = Object.defineProperty;
4
+ var __exportAll = (all, no_symbols) => {
5
+ let target = {};
6
+ for (var name in all) __defProp(target, name, {
7
+ get: all[name],
8
+ enumerable: true
9
+ });
10
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
11
+ return target;
12
+ };
13
+ export { __exportAll as t };
@@ -0,0 +1,123 @@
1
+
2
+ import { ComponentPropsWithRef, MouseEvent, ReactElement, ReactNode, RefObject } from "react";
3
+ /**
4
+ * A resting position.
5
+ * - `'closed'` and `'full'` are always available (`'closed'` only while the sheet is dismissible).
6
+ * - A number is a fraction (0..1) of the available height that is visible, e.g. `0.5`.
7
+ * - Any other string is a CSS length of visible sheet, e.g. `'76px'`, `'40dvh'`, `'min(320px, 50dvh)'`.
8
+ */
9
+ type Detent = 'closed' | 'full' | number | (string & {});
10
+ 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
+ }
13
+ /** Why the sheet is asking to open or close. */
14
+ type OpenChangeReason = 'trigger' | 'close-button' | 'escape' | 'back' | 'outside-press' | 'swipe';
15
+ interface OpenChangeDetails {
16
+ reason: OpenChangeReason;
17
+ }
18
+ interface RootProps {
19
+ children?: ReactNode;
20
+ open?: boolean;
21
+ defaultOpen?: boolean;
22
+ /** Called when the user opens or closes the sheet (not when the `open` prop changes). */
23
+ onOpenChange?: (open: boolean, details: OpenChangeDetails) => void;
24
+ /**
25
+ * 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').
27
+ */
28
+ detents?: readonly Detent[];
29
+ /**
30
+ * Modal (default): a backdrop, the rest of the page inert, page scroll locked, focus moved into the sheet.
31
+ * Non-modal: the page stays visible and usable around the sheet; taps outside it go to the page, and Escape only
32
+ * acts while focus is inside the sheet. Read when the sheet opens: changing it while open applies on the next open.
33
+ */
34
+ modal?: boolean;
35
+ /**
36
+ * Whether a swipe down, Escape or an outside press can close the sheet (default true). When false they drop it
37
+ * to its lowest detent instead; `open={false}` and `<Sheet.Close>` still close it.
38
+ */
39
+ dismissible?: boolean;
40
+ /**
41
+ * Android back closes the sheet (the top one when stacked), like a native sheet; on a sheet that isn't
42
+ * dismissible it drops to the lowest detent. Default: true for modal sheets, false for non-modal ones.
43
+ * Uses `CloseWatcher` (Chrome, Firefox), so no history entries are added and URLs are never touched.
44
+ * Browsers without it (Safari) ignore this. The browser may let back navigate instead when the page
45
+ * hasn't been interacted with since the sheet opened (its anti-trap rule).
46
+ */
47
+ closeOnBack?: boolean;
48
+ /**
49
+ * The full detent reaches the very top of the screen (no gap above the sheet). Sets `data-ybs-fullscreen` on the
50
+ * root; `/styled` then squares off the corners as the sheet arrives at the top and pads the header by the top
51
+ * safe area. Headless: pad your header with `env(safe-area-inset-top)` yourself.
52
+ */
53
+ fullscreen?: boolean;
54
+ /**
55
+ * The page behind recedes while this modal sheet is open, like iOS. Mark your app's wrapper with
56
+ * `data-ybs-background`; `/styled` scales it back and rounds its top corners. Headless: style
57
+ * `html[data-ybs-recede] [data-ybs-background]` with `--ybs-recede` (0..1) and `--ybs-scroll-y`.
58
+ * While receding, `position: fixed` elements inside the wrapper are positioned against it.
59
+ */
60
+ recede?: boolean;
61
+ /** Detent to open at. Default: the smallest usable partial detent, else `full`. */
62
+ initialDetent?: Detent;
63
+ /** Controlled resting detent. Changing it moves the sheet. */
64
+ detent?: Detent;
65
+ /** The sheet came to rest at a detent. Never `'closed'`: closing is `onOpenChange(false)`. */
66
+ onDetentChange?: (detent: Detent) => void;
67
+ /** Receives the controller's internal decision log (for device testing). */
68
+ debug?: (message: string) => void;
69
+ }
70
+ /** How the sheet was opened, as Base UI reports it to `initialFocus`. */
71
+ type InteractionType = 'mouse' | 'touch' | 'pen' | 'keyboard';
72
+ /**
73
+ * Where focus goes when the sheet opens (Base UI's `initialFocus`). Omitted: the sheet itself for a modal sheet, no
74
+ * move for a non-modal one. `false`: don't move it. `true`: the sheet itself. A ref: that element. A function: called
75
+ * with how the sheet was opened; return an element, `true` for the sheet, `null` to fall back to the default, or
76
+ * `false`/`undefined` to do nothing.
77
+ */
78
+ type InitialFocus = boolean | RefObject<HTMLElement | null> | ((openType: InteractionType) => boolean | void | HTMLElement | null);
79
+ declare function Root(props: RootProps): import("react").JSX.Element;
80
+ /** What `Trigger`'s `render` function receives: spread it onto your own button. */
81
+ interface TriggerRenderProps {
82
+ type: 'button';
83
+ 'aria-haspopup': 'dialog';
84
+ 'aria-expanded': boolean;
85
+ onClick: (e: MouseEvent<HTMLElement>) => void;
86
+ }
87
+ interface TriggerProps extends ComponentPropsWithRef<'button'> {
88
+ /**
89
+ * Render your own button (a design-system `<Button>`) instead of ours:
90
+ * `render={(props) => <Button variant="paper" {...props}>Open</Button>}`. It receives `type`, `onClick` and the
91
+ * ARIA state; anything else (className, disabled, aria-label) goes on your button directly.
92
+ */
93
+ render?: (props: TriggerRenderProps) => ReactElement;
94
+ }
95
+ declare function Trigger({ render, ...props }: TriggerProps): import("react").JSX.Element;
96
+ declare function Close(props: ComponentPropsWithRef<'button'>): import("react").JSX.Element;
97
+ interface PortalProps {
98
+ children?: ReactNode;
99
+ /** Where to render. Default document.body. */
100
+ container?: HTMLElement | null;
101
+ }
102
+ /** Fixed layer. Mounted while open or animating closed. A modal sheet makes the rest of the page inert. */
103
+ declare function Portal({ children, container }: PortalProps): import("react").ReactPortal | null;
104
+ declare function Backdrop(props: ComponentPropsWithRef<'div'>): import("react").JSX.Element;
105
+ /** Props go on the scroller (the one scroll container); `bleedClassName` on the bleed layer. */
106
+ interface ViewProps extends ComponentPropsWithRef<'div'> {
107
+ /** className for the bleed layer (sheet colour under the visual viewport). */
108
+ bleedClassName?: string;
109
+ }
110
+ /** The single native scroller: spacer (closed area), detent markers, and the sheet (Sheet.Content). */
111
+ declare function View({ children, bleedClassName, ref, ...props }: ViewProps): import("react").JSX.Element;
112
+ interface ContentProps extends ComponentPropsWithRef<'section'> {
113
+ /** Where focus goes when the sheet opens (like Base UI's `initialFocus`). See `InitialFocus`. */
114
+ initialFocus?: InitialFocus;
115
+ }
116
+ declare function Content({ ref, initialFocus, ...props }: ContentProps): import("react").JSX.Element;
117
+ /** Sticky top part of the sheet. Mouse-draggable on desktop. */
118
+ declare function Header(props: ComponentPropsWithRef<'div'>): import("react").JSX.Element;
119
+ /** Visual grabber. Decorative; dragging works on the whole Header. */
120
+ declare function Handle(props: ComponentPropsWithRef<'div'>): import("react").JSX.Element;
121
+ declare function Title(props: ComponentPropsWithRef<'h2'>): import("react").JSX.Element;
122
+ declare function Description(props: ComponentPropsWithRef<'p'>): import("react").JSX.Element;
123
+ export { OpenChangeDetails as a, PortalProps as c, Trigger as d, TriggerProps as f, Detent as g, sheet_d_exports as h, InteractionType as i, Root as l, ViewProps as m, ContentProps as n, OpenChangeReason as o, TriggerRenderProps as p, InitialFocus as r, Portal as s, Close as t, RootProps as u };