@botiverse/raft-ui 0.97.0 → 0.98.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 CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.98.0](https://github.com/botiverse/raft-design/compare/v0.97.0...v0.98.0) (2026-11-13)
2
+
3
+ ### Added
4
+
5
+ - **A side drawer's `DrawerBody` can scroll in a ScrollArea, with raft-ui's scrollbar in place of the platform's** ([Drawer › Scrolling in a ScrollArea](https://raft-design.botiverse.build/components/drawer/#scrolling-in-a-scrollarea)). In a drawer from the right or the left, `<DrawerBody unstable_scrollArea>` renders a frame (`data-slot="drawer-body-frame"`) around the body and binds the body as a ScrollArea with absolute gutters, as `DialogBody` does with the same prop. The body keeps `data-slot="drawer-body"`, `className`, `ref`, the native props, `render` and its children, and is still the flex column they are laid out in, so a layout written on it (`className="gap-4"`) is unchanged and its padding still scrolls with the content; `wrapperClassName` is the frame's class, for the classes that size the body in the drawer (`max-h-*`, `h-*`, `flex-*`). The body is a Tab stop only while it overflows and holds nothing that takes focus (a `tabIndex` of your own wins), so a form gains no stop and a long text that is only read can be scrolled with the keyboard, and a scroll stops at its end (`overscroll="contain"`). A bottom or top sheet ignores both props and renders the body as before.
6
+ - Why: a side drawer's body was one of the few regions that still scrolled natively, because the body is a flex column callers lay out themselves and ScrollArea's sticky gutters would add a gap before its first item. On Windows, and on a Mac set to always show scrollbars, a long drawer drew the platform's scrollbar, which takes room from the content when it appears. Absolute gutters put nothing inside the body, at the price of one element around it. A bottom sheet stays native: it is a phone surface, where the platform's scrollbar lies over the content and takes no room.
7
+ - Affects: nothing unless the prop is passed: without it, and in a bottom or top sheet with it, `DrawerBody` renders the same element and classes as before. It becomes the default in a later minor release, together with `DialogBody`'s, and the prop can be deleted then. With it, the frame, not the body, is the child of `DrawerContent`: a selector such as `[data-slot=drawer-content] > [data-slot=drawer-body]` no longer matches, a sizing class on the body moves to `wrapperClassName`, and `render` replaces the body inside the frame, not the drawer's flex child. The frame is a stacking context of its own, so a popup portaled into the body is drawn under the drawer's header; leave popups where raft-ui portals them. Swiping toward the drawer's edge still dismisses it, and a vertical drag on the body scrolls it. `DialogBody` renders the same DOM as before; its Tab-stop check now lives in a file both bodies share.
8
+ - Docs: https://raft-design.botiverse.build/components/drawer/#scrolling-in-a-scrollarea
9
+
10
+ ### Fixed
11
+
12
+ - **A bottom sheet's `DrawerBody` scrolls** ([Drawer › Behavior](https://raft-design.botiverse.build/components/drawer/#behavior)). In a `Drawer` from the bottom or the top (`swipeDirection="down"`, the default, or `"up"`), the body now has `min-h-0 overflow-y-auto` of its own: a body taller than the sheet's 82% of the window scrolls natively between `DrawerHeader` and `DrawerFooter`, which stay in the sheet. In a sheet with `snapPoints` the body scrolls only at its full height (the snap point `1`) and is cut at the sheet's bottom edge below it (`overflow-y-hidden`), so a drag up on the body still raises the sheet.
13
+ - Why: only a side drawer's body scrolled by itself. A sheet's body grew with its content past the sheet's bottom edge, so the footer and the end of a long list were under the window's edge and nothing scrolled to them, unless the caller added the two classes or rendered a scroller as the body. It is native, not a ScrollArea: a bottom sheet is a phone surface, where the platform's scrollbar lies over the content and takes no room, and a drag down at the top of the body has to start closing the sheet.
14
+ - Affects: a sheet whose body is taller than the sheet now keeps its footer in view and scrolls; a sheet with less content has the same box as before and no scrollbar, and its body is no Tab stop. A caller with its own `min-h-0 overflow-y-auto` on the body, or its own ScrollArea as the body (`render`), sees nothing: the classes are now redundant and can be removed, and so can a `// raft-ui: native-scroll` comment written for them. Swiping is unchanged where it worked: a drag down on the header or the grab handle closes the sheet, and so does one on the body while it is at its top; with the body scrolled down, a drag down scrolls it back first. A body that counted on painting past its own box (a shadow or a focus ring drawn outside a `p-0` body) is now clipped at the body's edge, as in any scroller; `className="min-h-auto overflow-visible"` restores the old box. The body is still a flex column, and its children no longer shrink (`shrink-0` at no specificity, in a sheet and in a side drawer, with or without `unstable_scrollArea`): a row of a fixed height written in the body (`h-13` with one line of text) used to be squashed to its text before the body scrolled, and now keeps its height. A child with `flex-1` or `shrink` still shrinks, so an inner scroller with `min-h-0 flex-1` scrolls as before; one with `min-h-0` alone and no `flex-1` now keeps its content's height and the body scrolls instead. The body's class list gains `[:where(&)>*]:shrink-0`. A side drawer scrolls as before, with and without `unstable_scrollArea`; its children keep their height in the same way.
15
+ - Docs: https://raft-design.botiverse.build/components/drawer/#behavior
16
+
1
17
  ## [0.97.0](https://github.com/botiverse/raft-design/compare/v0.96.1...v0.97.0) (2026-11-13)
2
18
 
3
19
  ### Added
@@ -62,7 +62,7 @@ export type DialogBodyProps = ComponentPropsWithRef<"div"> & {
62
62
  * platform's. A frame (`data-slot="dialog-body-frame"`) is rendered around
63
63
  * the body and holds the scrollbars; the body keeps its slot, `className`,
64
64
  * `ref`, native props and children, so a layout written on it
65
- * (`grid gap-4`) is unchanged. It becomes the default in the next minor
65
+ * (`grid gap-4`) is unchanged. It becomes the default in a later minor
66
66
  * release: delete the prop then. A `bar` body ignores it.
67
67
  */
68
68
  unstable_scrollArea?: boolean;
@@ -5,6 +5,7 @@ import { PopupSheetReset } from "../../lib/popup-boundary.mjs";
5
5
  import { ScrollArea } from "../scroll-area/scroll-area.mjs";
6
6
  import { ScrollAreaGutters } from "../scroll-area/scroll-area-gutters.mjs";
7
7
  import { useScrollAreaContainer } from "../scroll-area/use-scroll-area-container.mjs";
8
+ import { useHoldsFocusable } from "../../lib/holds-focusable.mjs";
8
9
  import { stackedActions, useStackedActions } from "../../lib/stacked-actions.mjs";
9
10
  import { tv } from "../../lib/tv.mjs";
10
11
  import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
@@ -351,49 +352,6 @@ function DialogBodyScrollArea({ className, wrapperClassName, children, ref, tabI
351
352
  }), /* @__PURE__ */ jsx(ScrollAreaGutters, { ...guttersProps })]
352
353
  });
353
354
  }
354
- const FOCUSABLE = "a[href], button, input:not([type=\"hidden\"]), select, textarea, summary, iframe, audio[controls], video[controls], [contenteditable]:not([contenteditable=\"false\"]), [tabindex]";
355
- /** Whether Tab stops at `candidate`: enabled, not inert, and rendered. */
356
- function takesTab(candidate) {
357
- if (!candidate.isContentEditable && candidate.tabIndex < 0) return false;
358
- if (candidate.matches(":disabled") || candidate.closest("[inert]")) return false;
359
- if (typeof candidate.checkVisibility !== "function") return true;
360
- return candidate.checkVisibility({ visibilityProperty: true });
361
- }
362
- /** Whether `element` holds something Tab stops at, kept current as its content changes. */
363
- function useHoldsFocusable(element) {
364
- const [holds, setHolds] = useState(true);
365
- useEffect(() => {
366
- if (!element) return;
367
- const read = () => {
368
- for (const candidate of element.querySelectorAll(FOCUSABLE)) if (takesTab(candidate)) {
369
- setHolds(true);
370
- return;
371
- }
372
- setHolds(false);
373
- };
374
- read();
375
- if (typeof MutationObserver === "undefined") return;
376
- const observer = new MutationObserver(read);
377
- observer.observe(element, {
378
- childList: true,
379
- subtree: true,
380
- attributes: true,
381
- attributeFilter: [
382
- "disabled",
383
- "tabindex",
384
- "href",
385
- "inert",
386
- "contenteditable",
387
- "hidden",
388
- "class",
389
- "style",
390
- "open"
391
- ]
392
- });
393
- return () => observer.disconnect();
394
- }, [element]);
395
- return holds;
396
- }
397
355
  function DialogFooter({ className, bar = false, hidden, children, ref, ...props }) {
398
356
  const { sheet, bodyEndSlot } = useDialogSheet();
399
357
  const items = Children.toArray(children);
@@ -21,8 +21,26 @@ export type DrawerHeaderProps = useRender.ComponentProps<"div">;
21
21
  export declare function DrawerHeader({ className, render, children, ...props }: DrawerHeaderProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
22
22
  export type DrawerFooterProps = useRender.ComponentProps<"div">;
23
23
  export declare function DrawerFooter({ className, render, ...props }: DrawerFooterProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
24
- export type DrawerBodyProps = useRender.ComponentProps<"div">;
25
- export declare function DrawerBody({ className, render, ...props }: DrawerBodyProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
24
+ export type DrawerBodyProps = useRender.ComponentProps<"div"> & {
25
+ /**
26
+ * In a drawer from a side, scroll the body in a ScrollArea, with raft-ui's
27
+ * scrollbar in place of the platform's. A frame
28
+ * (`data-slot="drawer-body-frame"`) is rendered around the body and holds
29
+ * the scrollbars; the body keeps its slot, `className`, `ref`, native
30
+ * props, `render` and children, so a layout written on it (`gap-4`) is
31
+ * unchanged. It becomes the default in a later minor release: delete the
32
+ * prop then. A bottom or top sheet ignores it: its body is as without it.
33
+ */
34
+ unstable_scrollArea?: boolean;
35
+ /**
36
+ * With `unstable_scrollArea`, the class of the frame around the body: the
37
+ * classes that size the body in the drawer (`max-h-*`, `h-*`, `flex-*`) go
38
+ * here, not in `className`. Ignored without `unstable_scrollArea`, and in a
39
+ * bottom or top sheet.
40
+ */
41
+ wrapperClassName?: string;
42
+ };
43
+ export declare function DrawerBody({ className, render, unstable_scrollArea: scrollArea, wrapperClassName, ...props }: DrawerBodyProps): import("react").JSX.Element;
26
44
  export type DrawerActionsProps = useRender.ComponentProps<"div">;
27
45
  export declare function DrawerActions({ className, render, children, ...props }: DrawerActionsProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
28
46
  export type DrawerTitleProps = StyledProps<Drawer.Title.Props>;
@@ -1,12 +1,15 @@
1
1
  import { cn } from "../../cn.mjs";
2
+ import { useHoldsFocusable } from "../../lib/holds-focusable.mjs";
2
3
  import { PopupSheetContext, PopupSheetReset } from "../../lib/popup-boundary.mjs";
3
4
  import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
4
5
  import { Button } from "../button/button.mjs";
5
6
  import { DialogSheetReset } from "../dialog/dialog-sheet-context.mjs";
7
+ import { ScrollAreaGutters } from "../scroll-area/scroll-area-gutters.mjs";
8
+ import { useScrollAreaContainer } from "../scroll-area/use-scroll-area-container.mjs";
6
9
  import { drawer } from "./drawer.recipe.mjs";
7
10
  import { useParentOverlayKind, withOverlayLayer } from "../../lib/overlay-depth.mjs";
8
11
  import { X } from "lucide-react";
9
- import { createContext, useContext, useMemo, useState } from "react";
12
+ import { createContext, useCallback, useContext, useMemo, useState } from "react";
10
13
  import { jsx, jsxs } from "react/jsx-runtime";
11
14
  import { mergeProps } from "@base-ui/react/merge-props";
12
15
  import { useRender } from "@base-ui/react/use-render";
@@ -169,9 +172,27 @@ function DrawerFooter({ className, render, ...props }) {
169
172
  state: { slot: "drawer-footer" }
170
173
  });
171
174
  }
172
- function DrawerBody({ className, render, ...props }) {
175
+ function DrawerBody({ className, render, unstable_scrollArea: scrollArea = false, wrapperClassName, ...props }) {
173
176
  const { swipeAxis } = useDrawerConfig();
174
- const { body } = drawer({ axis: swipeAxis });
177
+ if (scrollArea && swipeAxis === "x") return /* @__PURE__ */ jsx(DrawerBodyScrollArea, {
178
+ className,
179
+ render,
180
+ wrapperClassName,
181
+ ...props
182
+ });
183
+ return /* @__PURE__ */ jsx(DrawerBodyNative, {
184
+ className,
185
+ render,
186
+ swipeAxis,
187
+ ...props
188
+ });
189
+ }
190
+ function DrawerBodyNative({ className, render, swipeAxis, ...props }) {
191
+ const { hasSnapPoints } = useDrawerConfig();
192
+ const { body } = drawer({
193
+ axis: swipeAxis,
194
+ snapPoints: hasSnapPoints
195
+ });
175
196
  const defaultProps = { className: body({ className }) };
176
197
  return useRender({
177
198
  defaultTagName: "div",
@@ -180,6 +201,42 @@ function DrawerBody({ className, render, ...props }) {
180
201
  state: { slot: "drawer-body" }
181
202
  });
182
203
  }
204
+ /**
205
+ * A side drawer's DrawerBody as a ScrollArea with absolute gutters: nothing is
206
+ * put inside the body, so its flex column holds only the caller's children.
207
+ */
208
+ function DrawerBodyScrollArea({ className, render, wrapperClassName, ref, tabIndex, ...props }) {
209
+ const { bodyFrame, bodyScroll } = drawer();
210
+ const [body, setBody] = useState(null);
211
+ const setRef = useCallback((element) => {
212
+ setBody(element);
213
+ if (typeof ref === "function") ref(element);
214
+ else if (ref) ref.current = element;
215
+ }, [ref]);
216
+ const holdsFocusable = useHoldsFocusable(body);
217
+ const { containerProps, frameProps, guttersProps } = useScrollAreaContainer({
218
+ containerRef: setRef,
219
+ guttersPosition: "absolute",
220
+ overscroll: "contain"
221
+ });
222
+ const defaultProps = { className: bodyScroll({ className: cn(containerProps.className, className) }) };
223
+ const element = useRender({
224
+ defaultTagName: "div",
225
+ render,
226
+ ref: containerProps.ref,
227
+ props: {
228
+ ...mergeProps(defaultProps, props),
229
+ tabIndex: tabIndex ?? (holdsFocusable ? -1 : containerProps.tabIndex)
230
+ },
231
+ state: { slot: "drawer-body" }
232
+ });
233
+ return /* @__PURE__ */ jsxs("div", {
234
+ ...frameProps,
235
+ "data-slot": "drawer-body-frame",
236
+ className: bodyFrame({ className: cn(frameProps.className, wrapperClassName) }),
237
+ children: [element, /* @__PURE__ */ jsx(ScrollAreaGutters, { ...guttersProps })]
238
+ });
239
+ }
183
240
  function DrawerActions({ className, render, children, ...props }) {
184
241
  const swipeAxis = useContext(DrawerConfigContext)?.swipeAxis;
185
242
  const { actions } = drawer({ axis: swipeAxis });
@@ -88,7 +88,17 @@ const drawer = tv({
88
88
  closeButton: [barButton],
89
89
  closeIcon: ["size-control-icon"],
90
90
  header: ["flex flex-col", "items-stretch gap-1 px-4 pt-4 pb-2 text-left"],
91
- body: ["flex flex-1 flex-col", "items-stretch px-4 pt-2 pb-4 text-left"],
91
+ body: [
92
+ "flex flex-1 flex-col",
93
+ "items-stretch px-4 pt-2 pb-4 text-left",
94
+ "[:where(&)>*]:shrink-0"
95
+ ],
96
+ bodyFrame: ["min-h-0 flex-1"],
97
+ bodyScroll: [
98
+ "flex min-h-0 flex-1 flex-col",
99
+ "items-stretch px-(--surface-inset) pt-(--page-top) pb-(--page-inset) text-left",
100
+ "[:where(&)>*]:shrink-0"
101
+ ],
92
102
  footer: ["mt-auto py-5", "px-4 pt-3 pb-4"],
93
103
  actions: [
94
104
  "flex w-full flex-col items-stretch",
@@ -159,7 +169,8 @@ const drawer = tv({
159
169
  "before:left-1/2 before:-translate-x-1/2",
160
170
  "h-4",
161
171
  "before:top-1.5 before:h-1 before:w-9"
162
- ]
172
+ ],
173
+ body: ["min-h-0 overflow-y-auto"]
163
174
  }
164
175
  }
165
176
  },
@@ -169,7 +180,10 @@ const drawer = tv({
169
180
  }, {
170
181
  axis: "y",
171
182
  snapPoints: true,
172
- class: { popup: ["[--drawer-content-height:100dvh]"] }
183
+ class: {
184
+ popup: ["[--drawer-content-height:100dvh]"],
185
+ body: ["overflow-y-hidden group-data-expanded/drawer-popup:overflow-y-auto"]
186
+ }
173
187
  }]
174
188
  });
175
189
  export { drawer };
@@ -0,0 +1,46 @@
1
+ import { useEffect, useState } from "react";
2
+ // src/lib/holds-focusable.ts
3
+ const FOCUSABLE = "a[href], button, input:not([type=\"hidden\"]), select, textarea, summary, iframe, audio[controls], video[controls], [contenteditable]:not([contenteditable=\"false\"]), [tabindex]";
4
+ /** Whether Tab stops at `candidate`: enabled, not inert, and rendered. */
5
+ function takesTab(candidate) {
6
+ if (!candidate.isContentEditable && candidate.tabIndex < 0) return false;
7
+ if (candidate.matches(":disabled") || candidate.closest("[inert]")) return false;
8
+ if (typeof candidate.checkVisibility !== "function") return true;
9
+ return candidate.checkVisibility({ visibilityProperty: true });
10
+ }
11
+ /** Whether `element` holds something Tab stops at, kept current as its content changes. */
12
+ function useHoldsFocusable(element) {
13
+ const [holds, setHolds] = useState(true);
14
+ useEffect(() => {
15
+ if (!element) return;
16
+ const read = () => {
17
+ for (const candidate of element.querySelectorAll(FOCUSABLE)) if (takesTab(candidate)) {
18
+ setHolds(true);
19
+ return;
20
+ }
21
+ setHolds(false);
22
+ };
23
+ read();
24
+ if (typeof MutationObserver === "undefined") return;
25
+ const observer = new MutationObserver(read);
26
+ observer.observe(element, {
27
+ childList: true,
28
+ subtree: true,
29
+ attributes: true,
30
+ attributeFilter: [
31
+ "disabled",
32
+ "tabindex",
33
+ "href",
34
+ "inert",
35
+ "contenteditable",
36
+ "hidden",
37
+ "class",
38
+ "style",
39
+ "open"
40
+ ]
41
+ });
42
+ return () => observer.disconnect();
43
+ }, [element]);
44
+ return holds;
45
+ }
46
+ export { useHoldsFocusable };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/raft-ui",
3
- "version": "0.97.0",
3
+ "version": "0.98.0",
4
4
  "description": "React components and CSS foundations for the Raft Design System.",
5
5
  "keywords": [
6
6
  "agent-skills",