@yourrealm/bottom-sheet 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ /*
2
+ * @yourrealm/bottom-sheet/styled: the preset look, plus the structural CSS it builds on. Import it once, before
3
+ * Tailwind: @import "@yourrealm/bottom-sheet/styled.css";
4
+ */
5
+ @import "./styles.css";
6
+ /*
7
+ * The polished preset. Override with the --ybs-* tokens.
8
+ * Light/dark follow the app's `color-scheme` (inherited from <html>), via light-dark(): an app with its own theme
9
+ * switch that sets `color-scheme` gets a matching sheet; `html { color-scheme: light dark }` follows the OS.
10
+ */
11
+ @layer ybs {
12
+ :root { --ybs-css-styled: 1; }
13
+ [data-ybs-root] {
14
+ --ybs-sheet-bg: light-dark(#fff, #1c1c1e);
15
+ --ybs-sheet-fg: light-dark(#111, #f2f2f7);
16
+ --ybs-muted: light-dark(#6b6b70, #98989f);
17
+ --ybs-handle: light-dark(rgba(60, 60, 67, 0.3), rgba(235, 235, 245, 0.3));
18
+ --ybs-divider: light-dark(rgba(60, 60, 67, 0.12), rgba(235, 235, 245, 0.12));
19
+ --ybs-radius: 14px;
20
+ --ybs-title-size: 17px;
21
+ /* Side padding of the body, matching the header. 0 for edge-to-edge lists that pad their own rows. */
22
+ --ybs-body-padding: 16px;
23
+ --ybs-backdrop: #000;
24
+ --ybs-backdrop-max: 0.4;
25
+ --ybs-max-width: 640px;
26
+ --ybs-content-width: 960px;
27
+ --ybs-float-gap: 8px;
28
+ --ybs-float-radius: 32px;
29
+ /* 1 within the last 10% of travel to full, else 0..: corners square off as a fullscreen sheet reaches the top. */
30
+ --ybs-at-top: clamp(0, (var(--ybs-progress, 0) - 0.9) * 10, 1);
31
+ }
32
+ .ybs-backdrop {
33
+ background: var(--ybs-backdrop);
34
+ opacity: calc(var(--ybs-progress, 0) * var(--ybs-backdrop-max));
35
+ }
36
+ .ybs-bleed { background: var(--ybs-sheet-bg); }
37
+ /* Stacked sheets: the covered sheet recedes (scales back and lifts slightly), like iOS. */
38
+ .ybs-view { transform-origin: 50% 0; transition: none; }
39
+ [data-ybs-root][data-ybs-covered] .ybs-view {
40
+ transform: translateY(calc(var(--ybs-covered, 0) * -6px)) scale(calc(1 - var(--ybs-covered, 0) * 0.06));
41
+ border-radius: var(--ybs-radius);
42
+ }
43
+ [data-ybs-root][data-ybs-covered] .ybs-bleed { opacity: 0; }
44
+ .ybs-sheet {
45
+ background: var(--ybs-sheet-bg);
46
+ color: var(--ybs-sheet-fg);
47
+ border-radius: var(--ybs-radius) var(--ybs-radius) 0 0;
48
+ box-shadow: 0 -1px 0 var(--ybs-divider), 0 -8px 30px rgba(0, 0, 0, 0.08);
49
+ padding-bottom: env(safe-area-inset-bottom, 0px);
50
+ font-family: system-ui, -apple-system, sans-serif;
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);
56
+ }
57
+ /* Full width: the surface spans the window; header and body keep their full width (no corner seams) but pad
58
+ their contents into a readable centred column. */
59
+ .ybs-sheet.ybs-sheet-full { max-width: none; --ybs-body-inset: max(var(--ybs-body-padding), (100cqw - var(--ybs-content-width)) / 2); }
60
+ .ybs-sheet-full > .ybs-header { padding-inline: max(16px, (100% - var(--ybs-content-width)) / 2); }
61
+
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. */
63
+ [data-ybs-root]:has(.ybs-sheet-page):not([data-ybs-fullscreen]) { --ybs-inset: calc(env(safe-area-inset-top, 0px) + 22px); }
64
+ /* A page sheet fills the screen at full even when its content is short (no fit-content). */
65
+ .ybs-sheet.ybs-sheet-page { min-height: calc(var(--ybs-vv-h, 100dvh) - var(--ybs-inset)); }
66
+ /* Header bar: leading | title | trailing, the title centred like an iOS navigation bar. */
67
+ .ybs-bar { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; }
68
+ .ybs-bar > .ybs-title { text-align: center; }
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
+ }
90
+ .ybs-trailing { justify-self: end; }
91
+ /* Floating (iOS 26): at lower detents the sheet floats, inset from the sides and the bottom with large corners;
92
+ over the last quarter of the travel to full it attaches (the gap closes, the bottom corners slide off-screen).
93
+ A clip-path, so nothing reflows while the sheet moves. --ybs-visible is the on-screen height of the sheet, so
94
+ 100% - visible is the part below the screen edge. */
95
+ .ybs-sheet.ybs-sheet-floating {
96
+ --ybs-float: calc(1 - clamp(0, (var(--ybs-progress, 0) - 0.75) * 4, 1));
97
+ --ybs-g: calc(var(--ybs-float-gap) * var(--ybs-float));
98
+ --ybs-rt: calc(var(--ybs-radius) + (var(--ybs-float-radius) - var(--ybs-radius)) * var(--ybs-float));
99
+ --ybs-rb: calc(var(--ybs-float-radius) * var(--ybs-float));
100
+ clip-path: inset(0 var(--ybs-g) calc((100% - var(--ybs-visible, 0px)) * var(--ybs-float) + var(--ybs-g)) var(--ybs-g)
101
+ round var(--ybs-rt) var(--ybs-rt) var(--ybs-rb) var(--ybs-rb));
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); }
105
+ /* Fullscreen: square corners at the top (they would show the page through), header clear of the notch. */
106
+ [data-ybs-fullscreen] .ybs-sheet, [data-ybs-fullscreen] .ybs-header {
107
+ --ybs-r: calc(var(--ybs-radius) * (1 - var(--ybs-at-top)));
108
+ border-radius: var(--ybs-r) var(--ybs-r) 0 0;
109
+ }
110
+ [data-ybs-fullscreen] .ybs-header { padding-top: calc(6px + env(safe-area-inset-top, 0px)); }
111
+ .ybs-header {
112
+ background: var(--ybs-sheet-bg);
113
+ border-radius: var(--ybs-radius) var(--ybs-radius) 0 0;
114
+ padding: 6px 16px 12px;
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;
119
+ /* Content scrolls under this sticky header; at fractional positions (device scaling, or a covered
120
+ sheet's scale) a 1px sliver of it can show above. Cover it with the sheet colour. */
121
+ box-shadow: 0 -2px 0 0 var(--ybs-sheet-bg);
122
+ }
123
+ [data-ybs-scrolled] .ybs-header { border-bottom-color: var(--ybs-divider); }
124
+ .ybs-handle {
125
+ width: 36px; height: 5px; border-radius: 3px;
126
+ background: var(--ybs-handle);
127
+ margin: 0 auto 10px;
128
+ }
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; }
134
+ .ybs-description { margin: 4px 0 0; font-size: 14px; color: var(--ybs-muted); }
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)); }
156
+ /* The page behind recedes (Sheet.Root recede + data-ybs-background on the app wrapper), like iOS: it scales back
157
+ from the top of the screen, drops below the status bar and rounds its top corners over black. The wrapper needs
158
+ its own background (html and body turn black). */
159
+ /* !important: our layer loses to the app's own unlayered html/body background, and this must win while receding. */
160
+ html[data-ybs-recede]:has([data-ybs-background]), html[data-ybs-recede]:has([data-ybs-background]) body { background: #000 !important; }
161
+ html[data-ybs-recede] [data-ybs-background] {
162
+ /* Like iOS, a sheet covering half the screen only dims the page; it recedes as the sheet covers the screen. */
163
+ --ybs-r: clamp(0, (var(--ybs-recede, 0) - 0.6) * 2.5, 1);
164
+ transform-origin: 50% var(--ybs-scroll-y, 0px);
165
+ transform: translateY(calc(var(--ybs-r) * (env(safe-area-inset-top, 0px) + 10px))) scale(calc(1 - var(--ybs-r) * 0.07));
166
+ clip-path: inset(var(--ybs-scroll-y, 0px) 0 0 0 round calc(var(--ybs-r) * 12px) calc(var(--ybs-r) * 12px) 0 0);
167
+ }
168
+ }
@@ -0,0 +1,106 @@
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-5lG9EBB5.js";
3
+ import { ComponentPropsWithRef, ReactElement, ReactNode } from "react";
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: {
9
+ Root: typeof Root;
10
+ Trigger: typeof Trigger;
11
+ Close: typeof Close;
12
+ Portal: typeof Portal;
13
+ Backdrop: (p: import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => import("react").JSX.Element;
14
+ View: (p: ViewProps) => import("react").JSX.Element;
15
+ Content: (p: ContentProps) => import("react").JSX.Element;
16
+ Header: (p: import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => import("react").JSX.Element;
17
+ Handle: (p: import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => import("react").JSX.Element;
18
+ Title: (p: import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>) => import("react").JSX.Element;
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;
28
+ };
29
+ interface BottomSheetBaseProps extends Omit<RootProps, 'children'> {
30
+ /**
31
+ * What opens the sheet. Content (text, elements) is placed inside our trigger button. A function renders your own
32
+ * button instead: `trigger={(props) => <Button variant="paper" {...props}>Open</Button>}` (it receives `type`,
33
+ * `onClick` and the ARIA state; put className, disabled, aria-label on your button).
34
+ */
35
+ trigger?: ReactNode | ((props: TriggerRenderProps) => ReactElement);
36
+ /** A small label above the title (a section, a category). Part of the title, so screen readers read both. */
37
+ eyebrow?: ReactNode;
38
+ description?: ReactNode;
39
+ /**
40
+ * - `'sheet'` (default): detents as given, a card-width sheet.
41
+ * - `'page'`: an iOS-style page sheet for a task (compose, edit): opens straight to full,
42
+ * spans the full width (`width` default `'full'`), and the page behind recedes (`recede` default true; mark your
43
+ * app wrapper with `data-ybs-background`). Pair it with `leading` / `trailing` (Cancel / Save).
44
+ */
45
+ variant?: 'sheet' | 'page';
46
+ /**
47
+ * - `'card'` (default for `sheet`): the sheet is at most 640px wide (`--ybs-max-width`), centred.
48
+ * - `'full'` (default for `page`): the sheet spans the window; its header and body stay in a centred column
49
+ * (`--ybs-content-width`, default 960px).
50
+ */
51
+ width?: 'card' | 'full';
52
+ /**
53
+ * iOS 26 style: at lower detents the sheet floats (inset from the screen edges, large corners) and attaches as it
54
+ * reaches full. Tokens: --ybs-float-gap (8px), --ybs-float-radius (32px).
55
+ */
56
+ floating?: boolean;
57
+ /** Header bar slots, iOS navigation-bar style: e.g. `leading={<BottomSheet.Close>Cancel</BottomSheet.Close>}`. The title centres between them. */
58
+ leading?: ReactNode;
59
+ trailing?: ReactNode;
60
+ /** Extra content placed in the sticky header under the title (e.g. a search field). */
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;
76
+ /**
77
+ * Where focus goes when the sheet opens, like Base UI's: an element ref, `false`, `true` (the sheet), or a function
78
+ * of how it was opened, e.g. `(type) => type !== 'touch' && saveRef.current`. Default: the sheet for a modal sheet,
79
+ * nothing for a non-modal one.
80
+ */
81
+ initialFocus?: InitialFocus;
82
+ children?: ReactNode;
83
+ className?: string;
84
+ contentProps?: ComponentPropsWithRef<'section'>;
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
+ });
97
+ /**
98
+ * The simple path:
99
+ * <BottomSheet trigger="Open" title="Settings">…</BottomSheet>
100
+ */
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
+ }
106
+ export type { ContentProps, Detent, InitialFocus, InteractionType, OpenChangeDetails, OpenChangeReason, PortalProps, RootProps, TriggerProps, TriggerRenderProps, ViewProps };
package/dist/styled.js ADDED
@@ -0,0 +1,125 @@
1
+ 'use client';
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
+ import { useEffect } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ // src/styled/index.tsx
6
+ const cx = (...c) => c.filter(Boolean).join(" ");
7
+ function withClass(Comp, cls) {
8
+ return (p) => /* @__PURE__ */ jsx(Comp, {
9
+ ...p,
10
+ className: cx(cls, p.className)
11
+ });
12
+ }
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 = {
18
+ Root,
19
+ Trigger,
20
+ Close,
21
+ Portal,
22
+ Backdrop: withClass(Backdrop, "ybs-backdrop"),
23
+ View: (p) => {
24
+ useEffect(() => checkCss("styled", "styled.css"), []);
25
+ return /* @__PURE__ */ jsx(View, {
26
+ ...p,
27
+ className: cx("ybs-view", p.className),
28
+ bleedClassName: cx("ybs-bleed", p.bleedClassName)
29
+ });
30
+ },
31
+ Content: withClass(Content, "ybs-sheet"),
32
+ Header: withClass(Header, "ybs-header"),
33
+ Handle: withClass(Handle, "ybs-handle"),
34
+ Title: withClass(Title, "ybs-title"),
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
+ })
50
+ };
51
+ /**
52
+ * The simple path:
53
+ * <BottomSheet trigger="Open" title="Settings">…</BottomSheet>
54
+ */
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 }) {
56
+ const root = variant === "page" ? {
57
+ recede: true,
58
+ ...rootProps
59
+ } : rootProps;
60
+ const bar = leading != null || trailing != null;
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, {
82
+ ...root,
83
+ children: [typeof trigger === "function" ? /* @__PURE__ */ jsx(StyledSheet.Trigger, { render: trigger }) : trigger != null && /* @__PURE__ */ jsx(StyledSheet.Trigger, {
84
+ className: "ybs-trigger",
85
+ children: trigger
86
+ }), /* @__PURE__ */ jsxs(StyledSheet.Portal, { children: [root.modal !== false && /* @__PURE__ */ jsx(StyledSheet.Backdrop, {}), /* @__PURE__ */ jsx(StyledSheet.View, { children: /* @__PURE__ */ jsxs(StyledSheet.Content, {
87
+ ...contentProps,
88
+ "aria-label": ariaLabel ?? contentProps?.["aria-label"],
89
+ initialFocus,
90
+ className: cx(width === "full" && "ybs-sheet-full", variant === "page" && "ybs-sheet-page", floating && "ybs-sheet-floating", className, contentProps?.className),
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
+ ]
118
+ }) })] })]
119
+ });
120
+ }
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 };
@@ -0,0 +1,105 @@
1
+ /*
2
+ * @yourrealm/bottom-sheet: structural CSS (the mechanics: overlay, scroller, snapping). Import it once, before
3
+ * Tailwind: @import "@yourrealm/bottom-sheet/styles.css"; (or styled.css, which includes it).
4
+ * Everything is in the `ybs` cascade layer, ordered between Tailwind v4's `base` and `components`, so preflight
5
+ * can't reset it and your utilities and unlayered CSS win. The order statement only counts if this comes first.
6
+ */
7
+ /*
8
+ * Structural CSS only: the mechanics that make the single-scroller sheet work.
9
+ * No colours, radii or spacing; those belong to the consumer or `/styled`.
10
+ *
11
+ * CSS variables set by the controller on [data-ybs-root]:
12
+ * --ybs-h available height (px)
13
+ * --ybs-vv-top visualViewport.offsetTop (px)
14
+ * --ybs-vv-h visualViewport.height (px)
15
+ * --ybs-progress 0..1, closed -> full
16
+ * --ybs-visible px of the sheet on screen (scrollTop, capped at full)
17
+ * --ybs-bleed 0|1, whether the bleed (sheet colour under the visual viewport) shows
18
+ * Consumer-settable:
19
+ * --ybs-inset gap above the sheet at the full detent (default: safe area + 10px; 0 with fullscreen)
20
+ * --ybs-sheet-bg colour of the bleed, should match the sheet
21
+ */
22
+ @layer theme, base, ybs, components, utilities;
23
+ @layer ybs {
24
+ /* Marker: tells the library this file is loaded (it logs an error when a sheet opens without it). */
25
+ :root { --ybs-css-core: 1; }
26
+ [data-ybs-root] {
27
+ --ybs-inset: calc(env(safe-area-inset-top, 0px) + 10px);
28
+ position: fixed; inset: 0; z-index: 50; pointer-events: none;
29
+ }
30
+ [data-ybs-root][data-ybs-fullscreen] { --ybs-inset: 0px; }
31
+ /* Fullscreen means the full detent covers the screen, even with short content. */
32
+ [data-ybs-root][data-ybs-fullscreen] [data-ybs-sheet] { min-height: calc(var(--ybs-vv-h, 100dvh) - var(--ybs-inset)); }
33
+ [data-ybs-backdrop] {
34
+ position: fixed; left: 0; right: 0; top: var(--ybs-vv-top, 0px); height: 200lvh;
35
+ pointer-events: auto;
36
+ }
37
+ [data-ybs-bleed] {
38
+ position: fixed; left: 0; right: 0; height: 100lvh;
39
+ top: calc(var(--ybs-vv-top, 0px) + var(--ybs-vv-h, 100dvh));
40
+ background: var(--ybs-sheet-bg, Canvas);
41
+ opacity: var(--ybs-bleed, 0);
42
+ }
43
+ [data-ybs-scroller] {
44
+ position: fixed; left: 0; right: 0;
45
+ top: calc(var(--ybs-vv-top, 0px) + var(--ybs-inset));
46
+ height: calc(var(--ybs-vv-h, 100dvh) - var(--ybs-inset));
47
+ overflow-y: auto;
48
+ overscroll-behavior: contain;
49
+ scroll-snap-type: y mandatory;
50
+ scrollbar-width: none;
51
+ pointer-events: auto;
52
+ }
53
+ [data-ybs-scroller]::-webkit-scrollbar { display: none; }
54
+ [data-ybs-root][data-ybs-drag] [data-ybs-scroller],
55
+ [data-ybs-root][data-ybs-glide] [data-ybs-scroller] { scroll-snap-type: none; }
56
+ [data-ybs-track] { position: relative; }
57
+ [data-ybs-spacer] { height: var(--ybs-h, 100dvh); touch-action: none; }
58
+ [data-ybs-markers] { position: absolute; top: 0; left: 0; width: 1px; height: 0; }
59
+ [data-ybs-marker] {
60
+ position: absolute; left: 0; width: 1px; height: 1px;
61
+ scroll-snap-align: start; pointer-events: none;
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; }
65
+ /* scroll-snap-stop: a fling may not skip the full position, in either direction. This is the native
66
+ way to stop momentum at the content top / full detent; the JS guard is only a fallback. (Firefox
67
+ Android: the JS overflow toggle made the next touch get swallowed as a "stop the fling" tap.) */
68
+ [data-ybs-sheet] { position: relative; scroll-snap-align: start; scroll-snap-stop: always; outline: none; }
69
+ [data-ybs-root][data-ybs-fit] [data-ybs-sheet] { scroll-snap-align: end; }
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
+ }
77
+ /* Content scrolled: a drag on the header moves the whole sheet (JS, "detached drag"), like native, instead of
78
+ scrolling the content back first. */
79
+ [data-ybs-root][data-ybs-scrolled] [data-ybs-drag] { touch-action: none; }
80
+ /* Non-modal: the layer lets taps, clicks and wheel through to the page; only the sheet takes them. A drag on
81
+ the sheet still scrolls the scroller (its scroll container), which is how the sheet moves. */
82
+ [data-ybs-root][data-ybs-modeless] [data-ybs-scroller] { pointer-events: none; }
83
+ [data-ybs-root][data-ybs-modeless] [data-ybs-sheet] { pointer-events: auto; }
84
+ /* iOS Safari won't scroll a pointer-events:none scroller from a touch on its pointer-events:auto child unless
85
+ there is a scroll container under the finger. Make each direct part of the sheet a 1px-wide horizontal
86
+ scroller (overflow-y stays clipped to its own height, so sticky headers keep sticking to the scroller); the
87
+ vertical drag then chains to the sheet scroller. WebKit touch only. */
88
+ @supports (-webkit-touch-callout: none) {
89
+ [data-ybs-root][data-ybs-modeless] [data-ybs-sheet] > * {
90
+ overflow-x: scroll; overflow-y: hidden; overscroll-behavior-x: none; scrollbar-width: none;
91
+ }
92
+ [data-ybs-root][data-ybs-modeless] [data-ybs-sheet] > *::-webkit-scrollbar { display: none; }
93
+ /* 1px of horizontal overflow, measured from the padding box so side padding can't absorb it. */
94
+ [data-ybs-root][data-ybs-modeless] [data-ybs-sheet] > *:not([data-ybs-header], [data-ybs-footer]) { position: relative; }
95
+ [data-ybs-root][data-ybs-modeless] [data-ybs-sheet] > *::after {
96
+ content: ''; position: absolute; top: 0; left: 0; width: calc(100% + 1px); height: 1px; pointer-events: none;
97
+ }
98
+ }
99
+ /* Stacked: a covered sheet is inert (set by the sheet above), so it can't be scrolled. Don't toggle its
100
+ overflow: Firefox re-snaps on overflow changes. The visual recede lives in /styled. */
101
+ @media (pointer: fine) {
102
+ [data-ybs-drag] { cursor: grab; user-select: none; -webkit-user-select: none; }
103
+ [data-ybs-root][data-ybs-drag="active"] [data-ybs-drag] { cursor: grabbing; }
104
+ }
105
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@yourrealm/bottom-sheet",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Native-feeling bottom sheet for the web. One native scroller for sheet travel and content.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/yourrealm/bottom-sheet.git",
9
+ "directory": "packages/bottom-sheet"
10
+ },
11
+ "homepage": "https://yourrealm.github.io/bottom-sheet/",
12
+ "bugs": "https://github.com/yourrealm/bottom-sheet/issues",
13
+ "keywords": [
14
+ "react",
15
+ "bottom-sheet",
16
+ "sheet",
17
+ "drawer",
18
+ "scroll-snap",
19
+ "mobile",
20
+ "headless"
21
+ ],
22
+ "type": "module",
23
+ "types": "./dist/index.d.ts",
24
+ "sideEffects": [
25
+ "*.css"
26
+ ],
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE",
31
+ "CHANGELOG.md"
32
+ ],
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js"
37
+ },
38
+ "./styled": {
39
+ "types": "./dist/styled.d.ts",
40
+ "import": "./dist/styled.js"
41
+ },
42
+ "./package.json": "./package.json",
43
+ "./styles.css": "./dist/styles.css",
44
+ "./styled.css": "./dist/styled.css"
45
+ },
46
+ "peerDependencies": {
47
+ "react": ">=19",
48
+ "react-dom": ">=19"
49
+ },
50
+ "devDependencies": {
51
+ "@types/react": "^19.3.0",
52
+ "@types/react-dom": "^19.2.0",
53
+ "react": "^19.3.1",
54
+ "react-dom": "^19.3.1",
55
+ "tsdown": "^0.23.4",
56
+ "typescript": "^7.0.2",
57
+ "vitest": "^5.0.5"
58
+ },
59
+ "scripts": {
60
+ "build": "tsdown && node scripts/build-css.mjs",
61
+ "test": "vitest run",
62
+ "typecheck": "tsc -p .",
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"
65
+ }
6
66
  }