@botiverse/raft-ui 0.81.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 (50) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/components/choice-list/choice-list.d.mts +8 -0
  3. package/dist/components/choice-list/choice-list.mjs +37 -16
  4. package/dist/components/choice-list/choice-list.recipe.mjs +1 -0
  5. package/dist/components/composer/composer-drop-overlay.d.mts +9 -4
  6. package/dist/components/composer/composer-drop-overlay.mjs +2 -5
  7. package/dist/components/dialog/dialog-sheet-context.mjs +7 -2
  8. package/dist/components/dialog/dialog-sheet.d.mts +3 -2
  9. package/dist/components/dialog/dialog-sheet.mjs +29 -43
  10. package/dist/components/dialog/dialog.d.mts +12 -1
  11. package/dist/components/dialog/dialog.mjs +50 -10
  12. package/dist/components/fit-toolbar/fit-toolbar.d.mts +110 -0
  13. package/dist/components/fit-toolbar/fit-toolbar.mjs +469 -0
  14. package/dist/components/fit-toolbar/fit-toolbar.recipe.mjs +21 -0
  15. package/dist/components/fit-toolbar/index.d.mts +2 -0
  16. package/dist/components/list-row/index.d.mts +2 -2
  17. package/dist/components/list-row/list-row.d.mts +9 -0
  18. package/dist/components/list-row/list-row.mjs +20 -1
  19. package/dist/components/list-row/list-row.recipe.d.mts +6 -3
  20. package/dist/components/list-row/list-row.recipe.mjs +8 -2
  21. package/dist/components/message-action-card/index.d.mts +2 -2
  22. package/dist/components/message-action-card/message-action-card.d.mts +8 -0
  23. package/dist/components/message-action-card/message-action-card.mjs +16 -1
  24. package/dist/components/message-action-card/message-action-card.recipe.mjs +5 -0
  25. package/dist/components/mobile-nav/mobile-nav-server.d.mts +21 -8
  26. package/dist/components/mobile-nav/mobile-nav-server.mjs +142 -13
  27. package/dist/components/resizable/resizable.recipe.mjs +4 -4
  28. package/dist/components/scroll-area/use-scrollbars.mjs +2 -1
  29. package/dist/components/segmented-control/segmented-control.d.mts +1 -1
  30. package/dist/components/segmented-control/segmented-control.mjs +11 -2
  31. package/dist/components/selection-toolbar/index.d.mts +2 -0
  32. package/dist/components/selection-toolbar/selection-toolbar.d.mts +100 -0
  33. package/dist/components/selection-toolbar/selection-toolbar.mjs +217 -0
  34. package/dist/components/selection-toolbar/selection-toolbar.recipe.mjs +42 -0
  35. package/dist/components/steps/otter-animation-visibility.mjs +45 -0
  36. package/dist/components/steps/otter-indicator.css +131 -106
  37. package/dist/components/steps/otter-indicator.d.mts +5 -8
  38. package/dist/components/steps/otter-indicator.mjs +38 -314
  39. package/dist/components/steps/ottie-assets/error.webp +0 -0
  40. package/dist/components/steps/ottie-assets/finish.webp +0 -0
  41. package/dist/components/steps/ottie-assets/process.webp +0 -0
  42. package/dist/components/steps/ottie-assets/wait.webp +0 -0
  43. package/dist/index.d.mts +5 -4
  44. package/dist/index.mjs +5 -4
  45. package/dist/lib/label-value-fit.mjs +33 -6
  46. package/dist/lib/use-mobile-viewport.mjs +5 -2
  47. package/dist/lib/use-soft-keyboard.mjs +72 -0
  48. package/dist/wip.d.mts +6 -3
  49. package/dist/wip.mjs +4 -3
  50. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,83 @@
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
+
43
+ ## [0.82.0](https://github.com/botiverse/raft-design/compare/v0.81.0...v0.82.0) (2026-11-09)
44
+
45
+ ### Changed
46
+
47
+ - **OtterIndicator uses Ottie's approved four poses**: relaxed side-lying, quiet typing, a puzzled head tilt with a question mark, and one celebratory hop. The SVG props and ref remain supported. It pauses outside the viewport and in hidden documents; reduced motion shows a still. The HTML art layer sizes from the SVG box so Safari/WebKit scales it correctly at 40px and larger.
48
+ - Why: the previous unrelated otter drawing and sleeping wait pose did not match Ottie or the recent-activity card (Artea's approved B study).
49
+ - Affects: consumers of OtterIndicator, including Raft's recent-activity card. Artwork loads as separate transparent WebP files only for a displayed status; there is no frame timer or per-frame React update.
50
+ - Docs: https://raft-design.botiverse.build/components/steps/#ottie-illustration
51
+
52
+ ### Breaking
53
+
54
+ - **`ComposerDropOverlayPrompt` takes its text as `children`, which is required**: the icon cluster stays, and the text under it is what you pass. It no longer falls back to the built-in "Drop files to attach", and children no longer replace the icons.
55
+ - Why: the component library carried product copy in English that no app could translate (Remy's helper-text sweep, #proj-workspace:#250).
56
+ - Affects: `<ComposerDropOverlayPrompt />` no longer type-checks; pass the text, e.g. `<ComposerDropOverlayPrompt>{t("dropToAttach")}</ComposerDropOverlayPrompt>`. A caller that passed children for a label without icons uses `ComposerDropOverlayLabel` instead.
57
+ - Docs: https://raft-design.botiverse.build/components/composer/
58
+
59
+ ### Added
60
+
61
+ - **Data parts beside the explanation parts**: three additive parts, so data no longer has to sit in a slot meant for a sentence that explains. Nothing existing changes: no prop, part or style is renamed or restyled.
62
+ - `ListRowSubtitle`: data about a row's subject under its title (an agent's description, a channel's topic, an app's byline), in the title's ink at 400 and `text-footnote`. It follows the row's tone and disabled state with the title. `ListRowDescription` stays the place for a setting's consequence.
63
+ - ChoiceList items take `meta`: lines of data people compare across rows (a quota, free disk, a count, a status), in `text-footnote` in `foreground-muted` as the description is, after the `description` lines. A row still draws at most two lines in all, the description's first; a meta line has a description line's height, so moving a line from one to the other doesn't move the rows. Radios and checkboxes are described by the description lines, then the meta lines.
64
+ - `MessageActionCardHeaderMeta`: the time on a resolved card, in Space Mono at the hint tier: a line of its own under the title, or the end of the `MessageActionCardHeaderDescription` line it is placed in ("Approved by Martin · 10:51").
65
+ - Why: grey text is to mean one of two things, a sentence written to explain or a component's own notes on its data (times, counts, IDs), and data people read as the content uses the body ink. Apps had put data in the explanation parts (`ListRowDescription`, a ChoiceList item's `description`, `MessageActionCardHeaderDescription`) because there was nowhere else for it; these parts give it a place before those parts are kept for explanations (Kirby, Remy's helper-text plan).
66
+ - Affects: nothing until an app uses them. Data moved into `ListRowSubtitle` gets darker and a size smaller than in `ListRowDescription`; data moved into a ChoiceList item's `meta` looks as it did in `description`; a time moved into `MessageActionCardHeaderMeta` is set in Space Mono at the hint tier.
67
+ - Docs: https://raft-design.botiverse.build/components/list-row/#data-and-explanation, https://raft-design.botiverse.build/components/choice-list/#content, https://raft-design.botiverse.build/components/message-action-card/#anatomy
68
+
69
+ ### Fixed
70
+
71
+ - **A Resizable seam's handle no longer covers the scrollbar beside it**: the 12px handle (24px under a coarse pointer) lies over the start of the panel after the boundary instead of straddling it; its hover and focus line stays on the seam.
72
+ - Why: a panel's scrollbar runs along its end edge, the seam's other side, and the centered handle covered its last 6px, where the thumb is, so dragging the thumb resized the panel instead (Raft app's Activity list, #proj-raft-ui:21bae128).
73
+ - Affects: the handle now takes the pointer over the first 12px of the next panel (24px by touch) instead of 6px on each side. Nothing else moves.
74
+ - Docs: https://raft-design.botiverse.build/components/resizable/#behavior
75
+
76
+ - **ScrollArea measures in the frame after mounting, not while React commits**: where a `ResizeObserver` watches the sizes, the first read of the container's `clientHeight` and `scrollHeight` waits for the next animation frame, as every later one already did. Without a `ResizeObserver` it still measures at once.
77
+ - Why: the read in the commit forced a layout of the whole page each time an area mounted. Entering a server in the Raft app mounts the sidebar, the message list and the panels at once, and those reads took about 120ms of layout (InNOut's measurement, #proj-frontend:#40).
78
+ - Affects: the frame runs before the paint that first shows the area, so nothing on screen changes. Until it runs, an overflowing area has no thumb length and no overflow classes, and `onEdgeChange` is first called in it: code that reads them in a layout effect right after mounting finds them unset, and can call `actionsRef.measure` for a measure at once.
79
+ - Docs: https://raft-design.botiverse.build/components/scroll-area/
80
+
1
81
  ## [0.81.0](https://github.com/botiverse/raft-design/compare/v0.80.0...v0.81.0) (2026-11-09)
2
82
 
3
83
  ### Fixed
@@ -11,6 +11,14 @@ export type ChoiceListItem = {
11
11
  * (null, false or "") is not drawn. Colour inside a line is the caller's.
12
12
  */
13
13
  description?: ReactNode | readonly ReactNode[];
14
+ /**
15
+ * Lines of data under the description: what people compare across rows,
16
+ * such as a quota, free disk or a count ("120 GB free · 3 agents"), drawn
17
+ * as the description is (muted), after it. The row still draws at most two
18
+ * lines under the name, the description's first. An empty line is not
19
+ * drawn. Colour inside a line is the caller's.
20
+ */
21
+ meta?: ReactNode | readonly ReactNode[];
14
22
  /** Drawn before the radio's or checkbox's row text: an avatar, an icon. */
15
23
  media?: ReactNode;
16
24
  /**
@@ -18,6 +18,13 @@ function isDrawn(line) {
18
18
  function descriptionLines(description) {
19
19
  return (Array.isArray(description) ? description : [description]).filter(isDrawn);
20
20
  }
21
+ function rowLines(item) {
22
+ const description = descriptionLines(item.description).slice(0, MAX_DESCRIPTION_LINES);
23
+ return {
24
+ description,
25
+ meta: descriptionLines(item.meta).slice(0, MAX_DESCRIPTION_LINES - description.length)
26
+ };
27
+ }
21
28
  function CutLine({ id, slot, className, children }) {
22
29
  const ref = useOverflowTitle();
23
30
  return /* @__PURE__ */ jsx("span", {
@@ -133,6 +140,7 @@ function ChoiceList(props) {
133
140
  if (process.env.NODE_ENV === "production") return;
134
141
  if (!ariaLabel && !ariaLabelledBy) console.warn("raft-ui: a ChoiceList has neither aria-label nor aria-labelledby.");
135
142
  if (items.some((item) => descriptionLines(item.description).length > MAX_DESCRIPTION_LINES)) console.warn(`raft-ui: a ChoiceList item has more than ${MAX_DESCRIPTION_LINES} description lines; only the first ${MAX_DESCRIPTION_LINES} are drawn.`);
143
+ else if (items.some((item) => descriptionLines(item.description).length + descriptionLines(item.meta).length > MAX_DESCRIPTION_LINES)) console.warn(`raft-ui: a ChoiceList item has more than ${MAX_DESCRIPTION_LINES} description and meta lines together; only the first ${MAX_DESCRIPTION_LINES} are drawn, the description's first.`);
136
144
  }, [
137
145
  ariaLabel,
138
146
  ariaLabelledBy,
@@ -166,13 +174,14 @@ function ChoiceList(props) {
166
174
  };
167
175
  const rowElement = (item) => {
168
176
  const id = `${baseId}-${items.indexOf(item)}`;
169
- const lines = descriptionLines(item.description).slice(0, MAX_DESCRIPTION_LINES);
177
+ const { description: lines, meta } = rowLines(item);
170
178
  const rowDisabled = disabled || item.disabled === true;
179
+ const describedBy = [lines.length > 0 ? `${id}-description` : null, meta.length > 0 ? `${id}-meta` : null].filter(Boolean);
171
180
  const markProps = {
172
181
  value: item.value,
173
182
  disabled: item.disabled,
174
183
  "aria-labelledby": `${id}-label`,
175
- "aria-describedby": lines.length > 0 ? `${id}-description` : void 0
184
+ "aria-describedby": describedBy.length > 0 ? describedBy.join(" ") : void 0
176
185
  };
177
186
  return /* @__PURE__ */ jsxs("label", {
178
187
  "data-slot": "choice-list-item",
@@ -188,20 +197,32 @@ function ChoiceList(props) {
188
197
  /* @__PURE__ */ jsxs("span", {
189
198
  "data-slot": "choice-list-body",
190
199
  className: slots.body(),
191
- children: [/* @__PURE__ */ jsx(CutLine, {
192
- id: `${id}-label`,
193
- slot: "choice-list-label",
194
- className: slots.label(),
195
- children: item.label
196
- }), lines.length > 0 ? /* @__PURE__ */ jsx("span", {
197
- id: `${id}-description`,
198
- className: "contents",
199
- children: lines.map((line, index) => /* @__PURE__ */ jsx(CutLine, {
200
- slot: "choice-list-description",
201
- className: slots.description(),
202
- children: line
203
- }, index))
204
- }) : null]
200
+ children: [
201
+ /* @__PURE__ */ jsx(CutLine, {
202
+ id: `${id}-label`,
203
+ slot: "choice-list-label",
204
+ className: slots.label(),
205
+ children: item.label
206
+ }),
207
+ lines.length > 0 ? /* @__PURE__ */ jsx("span", {
208
+ id: `${id}-description`,
209
+ className: "contents",
210
+ children: lines.map((line, index) => /* @__PURE__ */ jsx(CutLine, {
211
+ slot: "choice-list-description",
212
+ className: slots.description(),
213
+ children: line
214
+ }, index))
215
+ }) : null,
216
+ meta.length > 0 ? /* @__PURE__ */ jsx("span", {
217
+ id: `${id}-meta`,
218
+ className: "contents",
219
+ children: meta.map((line, index) => /* @__PURE__ */ jsx(CutLine, {
220
+ slot: "choice-list-meta",
221
+ className: slots.meta(),
222
+ children: line
223
+ }, index))
224
+ }) : null
225
+ ]
205
226
  })
206
227
  ]
207
228
  }, item.value);
@@ -22,6 +22,7 @@ const choiceList = tv({
22
22
  body: ["grid min-w-0 flex-1 gap-0.5"],
23
23
  label: ["truncate font-semibold text-foreground", "in-data-disabled:text-foreground-disabled"],
24
24
  description: ["block truncate font-sans text-footnote font-normal text-foreground-muted", "in-data-disabled:text-foreground-disabled"],
25
+ meta: ["block truncate font-sans text-footnote font-normal text-foreground-muted", "in-data-disabled:text-foreground-disabled"],
25
26
  action: [
26
27
  "relative z-10 col-start-2 flex h-lh items-center self-start pr-1",
27
28
  "mt-option-inset pointer-coarse:mt-[calc((--spacing(11)-1lh)/2)]",
@@ -1,9 +1,14 @@
1
+ import * as React from "react";
1
2
  import { useRender } from "@base-ui/react/use-render";
2
3
  //#region src/components/composer/composer-drop-overlay.d.ts
3
4
  export type ComposerDropOverlayProps = useRender.ComponentProps<"div">;
4
- export declare function ComposerDropOverlay({ className, render, ...props }: ComposerDropOverlayProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
5
+ export declare function ComposerDropOverlay({ className, render, ...props }: ComposerDropOverlayProps): React.ReactElement<unknown, string | React.JSXElementConstructor<any>>;
5
6
  export type ComposerDropOverlayLabelProps = useRender.ComponentProps<"span">;
6
- export declare function ComposerDropOverlayLabel({ className, render, ...props }: ComposerDropOverlayLabelProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
7
- export type ComposerDropOverlayPromptProps = ComposerDropOverlayLabelProps;
8
- export declare function ComposerDropOverlayPrompt({ children, ...props }: ComposerDropOverlayPromptProps): import("react").JSX.Element;
7
+ export declare function ComposerDropOverlayLabel({ className, render, ...props }: ComposerDropOverlayLabelProps): React.ReactElement<unknown, string | React.JSXElementConstructor<any>>;
8
+ export type ComposerDropOverlayPromptProps = Omit<ComposerDropOverlayLabelProps, "children"> & {
9
+ /** What the prompt says, such as "Drop files to attach": the caller's copy, in its locale. */
10
+ children: React.ReactNode;
11
+ };
12
+ /** The label with the files-and-upload icon cluster above the caller's text. */
13
+ export declare function ComposerDropOverlayPrompt({ children, ...props }: ComposerDropOverlayPromptProps): React.JSX.Element;
9
14
  //#endregion
@@ -28,12 +28,9 @@ function ComposerDropOverlayLabel({ className, render, ...props }) {
28
28
  props: mergeProps(defaultProps, props)
29
29
  });
30
30
  }
31
+ /** The label with the files-and-upload icon cluster above the caller's text. */
31
32
  function ComposerDropOverlayPrompt({ children, ...props }) {
32
33
  const { dropOverlayIcon, dropOverlayIconCluster } = composerDropOverlay();
33
- if (children) return /* @__PURE__ */ jsx(ComposerDropOverlayLabel, {
34
- ...props,
35
- children
36
- });
37
34
  return /* @__PURE__ */ jsxs(ComposerDropOverlayLabel, {
38
35
  ...props,
39
36
  children: [/* @__PURE__ */ jsxs("span", {
@@ -53,7 +50,7 @@ function ComposerDropOverlayPrompt({ children, ...props }) {
53
50
  children: /* @__PURE__ */ jsx(Image, { className: "text-foreground-muted" })
54
51
  })
55
52
  ]
56
- }), /* @__PURE__ */ jsx("span", { children: "Drop files to attach" })]
53
+ }), /* @__PURE__ */ jsx("span", { children })]
57
54
  });
58
55
  }
59
56
  //#endregion
@@ -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, {