@botiverse/raft-ui 0.82.0 → 0.83.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.
Files changed (28) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/components/dialog/dialog-sheet-context.mjs +7 -2
  3. package/dist/components/dialog/dialog-sheet.d.mts +3 -2
  4. package/dist/components/dialog/dialog-sheet.mjs +29 -43
  5. package/dist/components/dialog/dialog.d.mts +12 -1
  6. package/dist/components/dialog/dialog.mjs +50 -10
  7. package/dist/components/fit-toolbar/fit-toolbar.d.mts +110 -0
  8. package/dist/components/fit-toolbar/fit-toolbar.mjs +469 -0
  9. package/dist/components/fit-toolbar/fit-toolbar.recipe.mjs +21 -0
  10. package/dist/components/fit-toolbar/index.d.mts +2 -0
  11. package/dist/components/list-row/list-row.recipe.d.mts +3 -3
  12. package/dist/components/list-row/list-row.recipe.mjs +7 -2
  13. package/dist/components/mobile-nav/mobile-nav-server.d.mts +21 -8
  14. package/dist/components/mobile-nav/mobile-nav-server.mjs +142 -13
  15. package/dist/components/segmented-control/segmented-control.d.mts +1 -1
  16. package/dist/components/segmented-control/segmented-control.mjs +11 -2
  17. package/dist/components/selection-toolbar/index.d.mts +2 -0
  18. package/dist/components/selection-toolbar/selection-toolbar.d.mts +100 -0
  19. package/dist/components/selection-toolbar/selection-toolbar.mjs +217 -0
  20. package/dist/components/selection-toolbar/selection-toolbar.recipe.mjs +42 -0
  21. package/dist/index.d.mts +3 -2
  22. package/dist/index.mjs +3 -2
  23. package/dist/lib/label-value-fit.mjs +33 -6
  24. package/dist/lib/use-mobile-viewport.mjs +5 -2
  25. package/dist/lib/use-soft-keyboard.mjs +72 -0
  26. package/dist/wip.d.mts +6 -3
  27. package/dist/wip.mjs +4 -3
  28. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,45 @@
1
+ ## [0.83.0](https://github.com/botiverse/raft-design/compare/v0.82.0...v0.83.0) (2026-11-10)
2
+
3
+ ### Added
4
+
5
+ - **`placement="top"` on DialogContent**, on a desktop and a tablet (and on DialogSheetContent there): the dialog stays centered across, its top edge at `var(--dialog-top, 8vh)`, and it is never taller than the window less that top and 16px, so it never runs past the window's bottom. Its width still comes from `size`. The content carries `data-placement="top"`. To anchor the card to something, the app measures it and sets `--dialog-top` in the content's `style`; raft-ui measures nothing. Below 768px it does nothing.
6
+ - Why: search opens from the top bar's field, and its panel's top edge belongs on the field, which a centered dialog can't do (slock drew it on DialogSurface with its own positioning, #triage:#278).
7
+ - Affects: nothing until a dialog passes `placement="top"`: `"center"`, the default, renders the same DOM and classes as before.
8
+ - Docs: https://raft-design.botiverse.build/components/dialog/#at-the-top
9
+ - **On a phone every centered dialog moves up while the on-screen keyboard is open**, whatever its `placement`: its top edge goes where the phone's top bar ends (`--header-height` below the top of what is visible, under the status bar), it ends at least 16px above the keyboard, and it goes back to the middle when the keyboard closes. While lifted the content carries `data-soft-keyboard`. A phone sheet doesn't move; it keeps its own keyboard box.
10
+ - Why: a centered dialog covered by the keyboard is always wrong, its field or its buttons out of sight, so this is platform behavior and no app opts in (stdrc, 2026-10-21: centered unless the keyboard is open). slock did it with its own `narrow="top"`; that maps to the default now.
11
+ - Affects: every centered `DialogContent` below 768px, only while a text field in it has focus and the visible area is over 100px shorter than the window; at rest nothing changes. A DialogSheetContent's keyboard box now reads the keyboard through the same check, once a frame: a focused checkbox, radio or button no longer counts as typing. An app that lifted its dialogs itself (slock's `narrow="top"`) can drop that code.
12
+ - Docs: https://raft-design.botiverse.build/components/dialog/#the-keyboard
13
+ - **SelectionToolbar** (from `@botiverse/raft-ui/wip`): the floating toolbar for selected items (messages, tasks), in the BatchBar style. `SelectionToolbarDock` floats it 12px above the bottom (plus the safe area) and 20px from the sides; `SelectionToolbarCount` (with the number in `SelectionToolbarCountValue`, Space Mono), `SelectionToolbarHint`, `SelectionToolbarActions` with `SelectionToolbarAction` (`secondary`, `menu`, `moreItem`), `SelectionToolbarMore` with `SelectionToolbarMoreItems` in its menu, `SelectionToolbarItem` for another control such as a select-all checkbox, and `SelectionToolbarClose`. A floating panel's frame (`line-seam` ring, `shadow-float`) as tall as a head: 44px with `md` buttons 4px apart with a mouse, 56px with 44px bar buttons side by side showing their icons alone by touch. It is a Base UI Toolbar (one Tab stop, arrow keys inside) and never wraps: when its row doesn't fit the width it floats in, measured, the hint, the secondary actions, the labels (the first action's last) and then the other actions give way, the last first; the count and × stay.
14
+ - Why: the toolbar for selected items is one bar shared by message multi-select and task bulk actions, in Noel's BatchBar style: `md` ghost buttons with a 14px icon and a label, no primary (stdrc, 2026-11-10, #wg-task-v2); by touch it aligns with the head, so a phone fits the tasks' Status, Assignee and Nudge at 360px (Noel, 2026-11-10).
15
+ - Affects: nothing yet. The stable `MessageMultiSelectToolbar` and `MessageMultiSelectToolbarActions` keep their earlier bar until SelectionToolbar leaves wip; move to it from `@botiverse/raft-ui/wip` when you are ready.
16
+ - Docs: https://raft-design.botiverse.build/components/selection-toolbar/, https://raft-design.botiverse.build/components/#toolbar-for-selected-items
17
+ - **FitToolbar** (from `@botiverse/raft-ui/wip`): a Base UI Toolbar whose items give way in named steps as the region it sits in narrows: `label` (to an icon), `more` (into More), `collapse` (a group to one button) and `wrap` (a group to a line of its own, with `wrap`; groups with the same `wrapLine` share one, and a wrapped group gets back what its items gave before it wrapped). Items declare their steps and order with `useFitItem`, or are a `FitToolbarButton`; a `FitToolbarGroup overflow="scroll"` scrolls sideways instead, its `data-active` items kept in view; groups are `FitToolbarGroup` (`firstLine`, `wrapOrder`, `wrapLine`, `collapsed`, `collapseOrder`); More takes `useFitMore` and lists `FitToolbarMoreItems`. It measures its own region's width, never the window's, again when its content changes on its own, and writes each step as `data-fit-<step>`. A SegmentedControl inside it follows its `label` step; its own `iconsOnlyBelow` is off there.
18
+ - Why: one measured engine for every toolbar that has to give way (responsive.md "measure the content rather than guess a width"), used by SelectionToolbar now and meant for the list toolbars of #proj-raft-ui:#225, instead of a breakpoint guess or a hook per app. Its step names and API stay in wip until sindresorhus confirms them for those toolbars.
19
+ - Docs: https://raft-design.botiverse.build/components/fit-toolbar/
20
+
21
+ ### Changed
22
+
23
+ - **DialogSheetContent, DialogCommit and DialogSheetBack are stable**: import them, and their props types, from `@botiverse/raft-ui`. `@botiverse/raft-ui/wip` no longer exports them.
24
+ - Why: slock is moving its own dialogs, its phone sheets among them, onto raft-ui (#triage:#278), and a product builds on the stable entry.
25
+ - Affects: an import from `@botiverse/raft-ui/wip` fails; import from `@botiverse/raft-ui` instead. No app imported them from `/wip` (slock draws its sheets itself today), and the docs' examples have moved.
26
+ - Docs: https://raft-design.botiverse.build/components/dialog/#making-a-dialog-a-sheet
27
+
28
+ ### Fixed
29
+
30
+ - **A label/value list puts its labels above when a chip or a long word of a wrapping value can't fit beside its label**: DescriptionList `direction="horizontal"` and MessageActionCard's properties now also stack when one chip of a value that wraps either way (a row of chips wider than the list) is wider than the value column but no wider than the list, and likewise when a long word in such a value (an ID, a commit SHA, a URL) is: beside the label the chip was cut and the word ran past the value column. A word set to `overflow-wrap: anywhere` breaks where it must, and doesn't decide. There is no new prop.
31
+ - Why: the one-line rule let a row of chips wider than the list stay beside its labels, where one long chip was cut off with no way to read it (mobile #4507, the Computer page's Detected Runtimes chip).
32
+ - Affects: lists whose value wraps either way and holds a chip or a long word wider than the value column now stack instead of staying beside their labels (slock's Computer page among them). Such a word used to run past the value column in DescriptionList and break mid-word in MessageActionCard properties; it now reads whole on its own line. Every other list lays out as before; the extra measurement runs only for values wider than the list.
33
+ - Docs: https://raft-design.botiverse.build/components/description-list/#labels-go-above-only-when-a-value-doesnt-fit
34
+ - **The server tab's popover keeps one whole server row and opens on the current server**: `MobileNavServerPopoverList` is never shorter than one two-line row. Where the footer actions and the pages would leave it less inside 60% of the screen, `MobileNavServerPopoverContent` grows past 60% as far as that row needs; where the screen is too short even for that, the whole popover scrolls, the footer and the pages with it, in a ScrollArea. On open the list scrolls, with no animation, to the row with `aria-current="true"`, and focus goes to that row (a touch still focuses the popover itself), so focus no longer scrolls the list back to the top.
35
+ - Why: with 5 pages in the grid, a 360dp phone squeezed the list until the current server was half hidden, and the list always opened at the top (Lagoon, mobile #4500; Jasper's review of raft-design #1117).
36
+ - Affects: every `MobileNavServerPopoverContent`. A popover that fit before is unchanged except that it opens scrolled to the current server; one whose list was squeezed below a row is now taller than 60%, or scrolls as a whole. Opening from a mouse or keyboard focuses the current server's row instead of the first row; pass `initialFocus` to choose otherwise. The popover's parts now sit in a ScrollArea (`data-slot="mobile-nav-server-popover-scroll"`) inside the popup, so a selector for a direct child of `mobile-nav-server-popover-content` no longer matches them. Mark the current server's row with `aria-current="true"` for the scroll and the focus to find it.
37
+ - Docs: https://raft-design.botiverse.build/components/mobile-nav/#the-server-tab
38
+ - **A settings row's switch centers on its text, not on the SaveStatus line under it**: in a `ListRow variant="plain"` whose `ListRowBody` ends with a `SaveStatus`, both ends (the switch, value, chevron, checkbox, radio or button, and a leading icon, avatar or tile) center on the title and description. The row moves the line's reserved space (4px and one footnote line) into its bottom padding, so its height is unchanged and nothing moves when "Saved" or a one-line error appears. There is no new prop.
39
+ - Why: the ends centered on the whole body, the empty reserved line included, so the switch sat visibly below the text (slock's Administration › Hide #all channel; Jasper and Kirby, 2026-11-09, #proj-raft-ui:#220).
40
+ - Affects: in such rows the ends move up by half the reserved line (11px). A row without a `SaveStatus` is unchanged. An error that wraps still grows the row, and the ends move down by half of each extra line. Beside a title alone on a phone, the 30px switch keeps 12px above it, so that row is 76px instead of 70px. A local wrapper that put an invisible line under the switch to compensate (slock's) now lifts it too far: remove it.
41
+ - Docs: https://raft-design.botiverse.build/components/list-row/#anatomy, https://raft-design.botiverse.build/components/save-status/#behavior
42
+
1
43
  ## [0.82.0](https://github.com/botiverse/raft-design/compare/v0.81.0...v0.82.0) (2026-11-09)
2
44
 
3
45
  ### Changed
@@ -12,6 +12,11 @@ const DialogSheetContext = createContext(NO_SHEET);
12
12
  function useDialogSheet() {
13
13
  return useContext(DialogSheetContext);
14
14
  }
15
+ /**
16
+ * True around the DialogContent a `DialogSheetContent` draws as a phone
17
+ * sheet: the sheet has its own keyboard box, so the content doesn't lift.
18
+ */
19
+ const DialogSheetSurfaceContext = createContext(false);
15
20
  /** Inside the sheet's head: its × is a pushed page's leading action. */
16
21
  const DialogSheetHeadContext = createContext(false);
17
22
  function useInDialogSheetHead() {
@@ -23,7 +28,7 @@ function useInDialogSheetHead() {
23
28
  * button instead of taking the sheet's.
24
29
  */
25
30
  function DialogSheetReset({ children }) {
26
- return createElement(DialogSheetContext.Provider, { value: NO_SHEET }, createElement(DialogSheetHeadContext.Provider, { value: false }, children));
31
+ return createElement(DialogSheetSurfaceContext.Provider, { value: false }, createElement(DialogSheetContext.Provider, { value: NO_SHEET }, createElement(DialogSheetHeadContext.Provider, { value: false }, children)));
27
32
  }
28
33
  /**
29
34
  * A dialog drawn as a phone sheet (design: dialog.md "On a phone"). The
@@ -55,4 +60,4 @@ const dialogSheet = tv({ slots: {
55
60
  bodyEnd: ["mt-4 empty:hidden", "text-start font-sans text-footnote text-foreground-muted *:data-[slot=text-link]:text-footnote"]
56
61
  } });
57
62
  //#endregion
58
- export { DialogSheetContext, DialogSheetHeadContext, DialogSheetReset, NO_SHEET, dialogSheet, useDialogSheet, useInDialogSheetHead };
63
+ export { DialogSheetContext, DialogSheetHeadContext, DialogSheetReset, DialogSheetSurfaceContext, NO_SHEET, dialogSheet, useDialogSheet, useInDialogSheetHead };
@@ -10,9 +10,10 @@ export type DialogSheetContentProps = DialogContentProps;
10
10
  * up over its scrim; `DialogHeader` becomes a pushed page's head (× at the
11
11
  * start, the title, the `DialogCommit` button at the end), `DialogFooter` is
12
12
  * hidden (× is Cancel) and `DialogBody` scrolls on the phone's 16px margin
13
- * above the home indicator. Base UI keeps Esc, focus and close.
13
+ * above the home indicator. Base UI keeps Esc, focus and close. `placement`
14
+ * applies on a desktop and a tablet only: the sheet is docked to the bottom.
14
15
  */
15
- export declare function DialogSheetContent({ className, overlay, style, ref, children, ...props }: DialogSheetContentProps): import("react").JSX.Element;
16
+ export declare function DialogSheetContent({ className, overlay, placement, style, ref, children, ...props }: DialogSheetContentProps): import("react").JSX.Element;
16
17
  export type DialogCommitProps = ButtonProps;
17
18
  /**
18
19
  * The dialog's one commit button: a `Button`, written where it goes on a
@@ -1,8 +1,9 @@
1
1
  import { cn } from "../../cn.mjs";
2
2
  import { Button } from "../button/button.mjs";
3
- import { DialogSheetContext, dialogSheet, useDialogSheet } from "./dialog-sheet-context.mjs";
4
- import { DialogContent } from "./dialog.mjs";
3
+ import { DialogSheetContext, DialogSheetSurfaceContext, dialogSheet, useDialogSheet } from "./dialog-sheet-context.mjs";
5
4
  import { useMobileViewport } from "../../lib/use-mobile-viewport.mjs";
5
+ import { useSoftKeyboard } from "../../lib/use-soft-keyboard.mjs";
6
+ import { DialogContent } from "./dialog.mjs";
6
7
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
7
8
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
9
  import { createPortal } from "react-dom";
@@ -14,9 +15,10 @@ import { createPortal } from "react-dom";
14
15
  * up over its scrim; `DialogHeader` becomes a pushed page's head (× at the
15
16
  * start, the title, the `DialogCommit` button at the end), `DialogFooter` is
16
17
  * hidden (× is Cancel) and `DialogBody` scrolls on the phone's 16px margin
17
- * above the home indicator. Base UI keeps Esc, focus and close.
18
+ * above the home indicator. Base UI keeps Esc, focus and close. `placement`
19
+ * applies on a desktop and a tablet only: the sheet is docked to the bottom.
18
20
  */
19
- function DialogSheetContent({ className, overlay, style, ref, children, ...props }) {
21
+ function DialogSheetContent({ className, overlay, placement, style, ref, children, ...props }) {
20
22
  const phone = useMobileViewport();
21
23
  const [commitSlot, setCommitSlot] = useState(null);
22
24
  const [bodyEndSlot, setBodyEndSlot] = useState(null);
@@ -51,16 +53,20 @@ function DialogSheetContent({ className, overlay, style, ref, children, ...props
51
53
  ...style,
52
54
  ...keyboardBox
53
55
  } : style, [style, keyboardBox]);
54
- return /* @__PURE__ */ jsx(DialogContent, {
55
- ...props,
56
- ref: setRef,
57
- overlay: phone ? sheetOverlay : overlay,
58
- className: phone ? cn(className, sheet.content()) : className,
59
- style: phone ? sheetStyle : style,
60
- "data-sheet": phone ? "" : void 0,
61
- children: /* @__PURE__ */ jsx(DialogSheetContext.Provider, {
62
- value: state,
63
- children
56
+ return /* @__PURE__ */ jsx(DialogSheetSurfaceContext.Provider, {
57
+ value: phone,
58
+ children: /* @__PURE__ */ jsx(DialogContent, {
59
+ ...props,
60
+ ref: setRef,
61
+ placement: phone ? void 0 : placement,
62
+ overlay: phone ? sheetOverlay : overlay,
63
+ className: phone ? cn(className, sheet.content()) : className,
64
+ style: phone ? sheetStyle : style,
65
+ "data-sheet": phone ? "" : void 0,
66
+ children: /* @__PURE__ */ jsx(DialogSheetContext.Provider, {
67
+ value: state,
68
+ children
69
+ })
64
70
  })
65
71
  });
66
72
  }
@@ -132,36 +138,16 @@ function DialogCommit({ size, form, onClick, ...props }) {
132
138
  * keyboard. `undefined` otherwise (the CSS box).
133
139
  */
134
140
  function useSheetKeyboardBox(active) {
135
- const [box, setBox] = useState(void 0);
136
- useEffect(() => {
137
- const viewport = typeof window === "undefined" ? void 0 : window.visualViewport;
138
- if (!active || !viewport) return;
139
- const update = () => {
140
- const focused = document.activeElement;
141
- const keyboardUp = (focused instanceof HTMLInputElement || focused instanceof HTMLTextAreaElement || focused instanceof HTMLElement && focused.isContentEditable) && window.innerHeight - viewport.height > 100;
142
- const height = Math.round(Math.min(viewport.height, window.innerHeight * .9));
143
- const top = Math.round(Math.max(viewport.offsetTop, 0) + viewport.height - height);
144
- setBox((previous) => {
145
- if (!keyboardUp) return void 0;
146
- return previous?.top === `${top}px` && previous?.height === `${height}px` ? previous : {
147
- top: `${top}px`,
148
- bottom: "auto",
149
- height: `${height}px`
150
- };
151
- });
152
- };
153
- viewport.addEventListener("resize", update);
154
- viewport.addEventListener("scroll", update, { passive: true });
155
- document.addEventListener("focusin", update);
156
- document.addEventListener("focusout", update);
157
- return () => {
158
- viewport.removeEventListener("resize", update);
159
- viewport.removeEventListener("scroll", update);
160
- document.removeEventListener("focusin", update);
161
- document.removeEventListener("focusout", update);
141
+ const keyboard = useSoftKeyboard(active);
142
+ return useMemo(() => {
143
+ if (!keyboard) return void 0;
144
+ const height = Math.round(Math.min(keyboard.height, keyboard.windowHeight * .9));
145
+ return {
146
+ top: `${keyboard.top + keyboard.height - height}px`,
147
+ bottom: "auto",
148
+ height: `${height}px`
162
149
  };
163
- }, [active]);
164
- return active ? box : void 0;
150
+ }, [keyboard]);
165
151
  }
166
152
  /**
167
153
  * The commit is at the top of a sheet, so an error a failed commit adds near
@@ -22,8 +22,19 @@ export type DialogContentProps = StyledProps<Dialog.Popup.Props> & {
22
22
  overlay?: false | DialogOverlayProps;
23
23
  size?: "sm" | "md" | "lg" | "wide";
24
24
  layer?: 0 | 1;
25
+ /**
26
+ * Where the card sits in the window. `"center"` (the default): in the
27
+ * middle. `"top"`: centred across, its top edge at `var(--dialog-top, 8vh)`,
28
+ * and never taller than the window less that top and 16px, so it never runs
29
+ * past the window's bottom; its width is still `size`'s. To anchor the card
30
+ * to something on the page (search under the top bar's field), measure it
31
+ * and set `--dialog-top` in `style`. Desktop and tablet only: below 768px
32
+ * every centred dialog sits in the middle and moves up only while the
33
+ * on-screen keyboard is open (dialog.md, "On a phone").
34
+ */
35
+ placement?: "center" | "top";
25
36
  };
26
- export declare function DialogContent({ className, children, portalProps, overlay, size, layer, ...props }: DialogContentProps): import("react").JSX.Element;
37
+ export declare function DialogContent({ className, children, portalProps, overlay, size, layer, placement, ...props }: DialogContentProps): import("react").JSX.Element;
27
38
  export type DialogCloseProps = Dialog.Close.Props & Pick<ButtonProps, "variant" | "size"> & {
28
39
  className?: string;
29
40
  children?: ReactNode;
@@ -6,13 +6,15 @@ import { tv } from "../../lib/tv.mjs";
6
6
  import { useThemeScopeProps } from "../../lib/theme/theme-scope.mjs";
7
7
  import { useParentOverlayKind, withOverlayLayer } from "../../lib/overlay-depth.mjs";
8
8
  import { Button } from "../button/button.mjs";
9
- import { DialogSheetHeadContext, DialogSheetReset, dialogSheet, useDialogSheet, useInDialogSheetHead } from "./dialog-sheet-context.mjs";
9
+ import { DialogSheetHeadContext, DialogSheetReset, DialogSheetSurfaceContext, dialogSheet, useDialogSheet, useInDialogSheetHead } from "./dialog-sheet-context.mjs";
10
10
  import { ScrollArea } from "../scroll-area/scroll-area.mjs";
11
+ import { useMobileViewport } from "../../lib/use-mobile-viewport.mjs";
12
+ import { useSoftKeyboard } from "../../lib/use-soft-keyboard.mjs";
11
13
  import { TextLink } from "../text-link/text-link.mjs";
12
14
  import { PanelAction } from "../panel/panel-action.mjs";
13
15
  import { Dialog } from "@base-ui/react/dialog";
14
16
  import { ArrowLeft, X } from "lucide-react";
15
- import { Children, createContext, isValidElement, useContext, useEffect } from "react";
17
+ import { Children, createContext, isValidElement, useContext, useEffect, useState } from "react";
16
18
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
17
19
  import { mergeProps } from "@base-ui/react/merge-props";
18
20
  import { useRender } from "@base-ui/react/use-render";
@@ -59,12 +61,20 @@ const dialog = tv({
59
61
  lg: { content: ["max-w-160"] },
60
62
  wide: { content: ["max-w-4xl h-[min(760px,calc(100dvh-2rem))]"] }
61
63
  },
64
+ placement: {
65
+ center: {},
66
+ top: { content: ["md:top-(--dialog-top,8vh) md:translate-y-0", "md:max-h-[calc(100dvh-var(--dialog-top,8vh)-16px)]"] }
67
+ },
68
+ keyboard: {
69
+ false: {},
70
+ true: { content: ["data-soft-keyboard:top-[calc(var(--dialog-visible-top)+env(safe-area-inset-top,0px)+var(--header-height))] data-soft-keyboard:translate-y-0", "data-soft-keyboard:max-h-[calc(var(--dialog-visible-height)-env(safe-area-inset-top,0px)-var(--header-height)-16px)]"] }
71
+ },
62
72
  bar: {
63
73
  false: {},
64
74
  true: {
65
- header: ["flex h-(--header-height) shrink-0 items-center gap-2 border-b px-(--surface-inset) py-0", "border-line-seam pt-0 [&>[data-slot=dialog-close]]:my-0"],
66
- body: ["flex min-h-0 flex-1 overflow-hidden p-0", "px-0 pt-0 pb-0 first:pt-0 text-foreground"],
67
- footer: ["shrink-0 border-t px-(--surface-inset) py-3", "border-line-seam pt-3 pb-3 first:pt-3 [[data-slot=dialog-header]+&]:pt-3"]
75
+ header: ["flex h-(--header-height) shrink-0 items-center gap-2 border-b px-(--surface-inset) py-0", "border-line-seam pt-0 *:data-[slot=dialog-close]:my-0"],
76
+ body: ["flex min-h-0 flex-1 overflow-hidden p-0", "p-0 first:pt-0 text-foreground"],
77
+ footer: ["shrink-0 border-t px-(--surface-inset) py-3", "border-line-seam py-3 first:pt-3 [[data-slot=dialog-header]+&]:pt-3"]
68
78
  }
69
79
  },
70
80
  layer: {
@@ -125,10 +135,16 @@ function DialogOverlay({ className, layer, ...props }) {
125
135
  });
126
136
  }
127
137
  const DialogSizeContext = createContext(void 0);
128
- function DialogContent({ className, children, portalProps, overlay, size, layer, ...props }) {
138
+ function DialogContent({ className, children, portalProps, overlay, size, layer, placement, ...props }) {
139
+ const top = placement === "top";
140
+ const sheetSurface = useContext(DialogSheetSurfaceContext);
141
+ const [keyboard, setKeyboard] = useState(null);
142
+ const raised = !sheetSurface && keyboard != null;
129
143
  const { content } = dialog({
130
144
  size,
131
- layer
145
+ layer,
146
+ placement,
147
+ keyboard: raised || void 0
132
148
  });
133
149
  const overlayProps = typeof overlay === "object" ? overlay : {};
134
150
  return /* @__PURE__ */ jsxs(DialogPortal, {
@@ -136,17 +152,41 @@ function DialogContent({ className, children, portalProps, overlay, size, layer,
136
152
  children: [overlay === false ? null : /* @__PURE__ */ jsx(DialogOverlay, {
137
153
  layer,
138
154
  ...overlayProps
139
- }), /* @__PURE__ */ jsx(Dialog.Popup, {
155
+ }), /* @__PURE__ */ jsxs(Dialog.Popup, {
140
156
  "data-slot": "dialog-content",
157
+ "data-placement": top ? "top" : void 0,
158
+ "data-soft-keyboard": raised ? "" : void 0,
141
159
  className: content({ className }),
142
160
  ...props,
143
- children: /* @__PURE__ */ jsx(DialogSizeContext.Provider, {
161
+ ...raised ? { style: withVisibleArea(props.style, keyboard) } : null,
162
+ children: [sheetSurface ? null : /* @__PURE__ */ jsx(SoftKeyboardWatch, { onChange: setKeyboard }), /* @__PURE__ */ jsx(DialogSizeContext.Provider, {
144
163
  value: size ?? (/(^|\s)max-w-/.test(className ?? "") ? void 0 : "md"),
145
164
  children: /* @__PURE__ */ jsx(DialogSheetReset, { children })
146
- })
165
+ })]
147
166
  })]
148
167
  });
149
168
  }
169
+ /**
170
+ * Reports the on-screen keyboard on a phone to a centred dialog, which moves
171
+ * up while it is open (dialog.md, "On a phone"). Rendered inside the popup,
172
+ * so it listens only while the dialog is open, and only below 768px.
173
+ */
174
+ function SoftKeyboardWatch({ onChange }) {
175
+ const keyboard = useSoftKeyboard(useMobileViewport());
176
+ useEffect(() => {
177
+ onChange(keyboard);
178
+ }, [keyboard, onChange]);
179
+ useEffect(() => () => onChange(null), [onChange]);
180
+ return null;
181
+ }
182
+ /** The popup's style with the area the keyboard leaves visible, for its CSS. */
183
+ function withVisibleArea(style, keyboard) {
184
+ return {
185
+ ...style,
186
+ "--dialog-visible-top": `${keyboard.top}px`,
187
+ "--dialog-visible-height": `${keyboard.height}px`
188
+ };
189
+ }
150
190
  function DialogClose({ className, variant, size, children, render, "aria-label": ariaLabel, ...props }) {
151
191
  const inSheetHead = useInDialogSheetHead();
152
192
  if (render) return /* @__PURE__ */ jsx(Dialog.Close, {
@@ -0,0 +1,110 @@
1
+ import { StyledProps } from "../../lib/styled-props.mjs";
2
+ import { ButtonProps } from "../button/button.mjs";
3
+ import { ReactNode, Ref } from "react";
4
+ import { Toolbar } from "@base-ui/react/toolbar";
5
+ //#region src/components/fit-toolbar/fit-toolbar.d.ts
6
+ /**
7
+ * The ways an item gives way, each one step:
8
+ * - `label`: its label hides and its icon stays (the label stays its accessible name);
9
+ * - `more`: it moves into More;
10
+ * - `collapse`: a group shows its one button instead of its items (a Filter button for its filters);
11
+ * - `wrap`: a group moves to a line of its own, only in a toolbar that allows it.
12
+ */
13
+ export type FitStep = "label" | "more" | "collapse" | "wrap";
14
+ /** When each of an item's steps comes: lower gives way first; at the same number, the last item first. */
15
+ export type FitSteps = Partial<Record<FitStep, number>>;
16
+ export type FitToolbarProps = StyledProps<Toolbar.Root.Props> & {
17
+ ref?: Ref<HTMLDivElement>;
18
+ /**
19
+ * Groups may move to a line of their own (the `wrap` step) when the row
20
+ * runs out of room. Off for a toolbar that floats over content: a second
21
+ * line would cover it, so its last step is into More.
22
+ */
23
+ wrap?: boolean;
24
+ };
25
+ /**
26
+ * A toolbar whose items give way, in named steps and an order the items
27
+ * declare, as the region it sits in narrows: it measures its content in its
28
+ * own width, never the window's. The steps are written on each item as
29
+ * `data-fit-<step>` for its styles. A Base UI Toolbar: one Tab stop, the
30
+ * arrow keys inside.
31
+ */
32
+ export declare function FitToolbar({ className, wrap, ref, ...props }: FitToolbarProps): import("react").JSX.Element;
33
+ /**
34
+ * Inside a FitToolbar: a control that would measure its own fit (a
35
+ * SegmentedControl's `iconsOnlyBelow`) leaves it to the toolbar's steps.
36
+ */
37
+ export declare function useInFitToolbar(): boolean;
38
+ export type FitItemOptions = FitSteps & {
39
+ /** What the item is in More's menu while it is there (`FitToolbarMoreItems`). */
40
+ moreItem?: ReactNode;
41
+ };
42
+ /**
43
+ * Makes an element an item of the nearest FitToolbar: spread `props` on it.
44
+ * Says which steps it has taken, for what it renders (a tooltip once its
45
+ * label is gone, say).
46
+ */
47
+ export declare function useFitItem({ moreItem, ...steps }?: FitItemOptions): {
48
+ id: string;
49
+ props: Record<string, string | undefined>;
50
+ /** Steps it has taken. */
51
+ taken: (step: FitStep) => boolean;
52
+ };
53
+ /**
54
+ * Makes an element the toolbar's More: spread `props` on it. It shows only
55
+ * while an item is in More (`data-fit-shown`); hide it otherwise.
56
+ */
57
+ export declare function useFitMore(): {
58
+ shown: boolean;
59
+ props: Record<string, string | undefined>;
60
+ };
61
+ /** The items in More, in their order, each as its `moreItem`: put it in More's menu. */
62
+ export declare function FitToolbarMoreItems(): import("react").JSX.Element[] | null;
63
+ export type FitToolbarGroupProps = StyledProps<Toolbar.Group.Props> & {
64
+ /** When it moves to a line of its own (the `wrap` step), in a toolbar that allows it. */
65
+ wrapOrder?: number;
66
+ /** Stays on the first line: it never wraps. The page decides which groups do. */
67
+ firstLine?: boolean;
68
+ /**
69
+ * Groups with the same `wrapLine` wrap onto the same line, in their order, a
70
+ * fixed gap apart; a group without one takes a line of its own.
71
+ */
72
+ wrapLine?: string;
73
+ /** When it shows `collapsed` instead of its items (the `collapse` step). */
74
+ collapseOrder?: number;
75
+ /** The group as one button (a Filter button for its filters), shown once it collapses. */
76
+ collapsed?: ReactNode;
77
+ /**
78
+ * `scroll`: the group takes the rest of its line and scrolls sideways
79
+ * instead of giving way, its items whole, the side that hides more fading.
80
+ * Mark an applied or chosen item `data-active`: it is kept in view.
81
+ */
82
+ overflow?: "scroll";
83
+ };
84
+ /**
85
+ * A group of items in one row, a fixed gap apart. A toolbar wraps only
86
+ * between groups, the last first; groups with the same `wrapLine` share a
87
+ * line, any other takes one of its own.
88
+ */
89
+ export declare function FitToolbarGroup({ className, wrapOrder, firstLine, wrapLine, collapseOrder, collapsed, overflow, children, ref, ...props }: FitToolbarGroupProps & {
90
+ ref?: Ref<HTMLDivElement>;
91
+ }): import("react").JSX.Element;
92
+ export type FitToolbarButtonProps = Omit<ButtonProps, "children" | "size"> & {
93
+ /** Its icon, which stays when its label gives way. */
94
+ icon: ReactNode;
95
+ /** Its label: its accessible name and, once it has given way, its tooltip. */
96
+ children: ReactNode;
97
+ /** When its label gives way (the `label` step). */
98
+ labelOrder?: number;
99
+ /** When it moves into More (the `more` step). */
100
+ moreOrder?: number;
101
+ /** What it is in More's menu. */
102
+ moreItem?: ReactNode;
103
+ };
104
+ /**
105
+ * A toolbar button that can give way: an `md` Button (ghost unless you say)
106
+ * with an icon and a label, an icon square with its label as a tooltip from
107
+ * `labelOrder` on, and hidden from `moreOrder` on.
108
+ */
109
+ export declare function FitToolbarButton({ icon, children, labelOrder, moreOrder, moreItem, variant, className, ...props }: FitToolbarButtonProps): import("react").JSX.Element;
110
+ //#endregion