@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
@@ -1,4 +1,5 @@
1
1
  import { tv } from "../../lib/tv.mjs";
2
+ import { scrollToElement } from "../scroll-area/scroll-to-element.mjs";
2
3
  import { ScrollArea } from "../scroll-area/scroll-area.mjs";
3
4
  import { MobileNavItem } from "./mobile-nav.mjs";
4
5
  import "../../lib/touch-drag.mjs";
@@ -16,9 +17,10 @@ const mobileNavServer = tv({ slots: {
16
17
  content: [
17
18
  "flex flex-col",
18
19
  "w-[calc(var(--anchor-width)-16px)] min-w-0",
19
- "max-h-[min(60dvh,calc(var(--available-height)-env(safe-area-inset-top,0px)))]"
20
+ "max-h-[min(max(60dvh,var(--mobile-nav-server-popover-floor,0px)),calc(var(--available-height)-env(safe-area-inset-top,0px)))]"
20
21
  ],
21
- list: ["flex min-h-0 flex-1 flex-col p-1"],
22
+ scroll: ["flex min-h-0 flex-col"],
23
+ list: ["flex min-h-[calc(var(--spacing-option)+var(--text-mono-meta--line-height)+(--spacing(2)))] flex-1 flex-col p-1"],
22
24
  group: ["flex shrink-0 flex-col p-1"],
23
25
  row: [
24
26
  "flex w-full shrink-0 cursor-default select-none items-center",
@@ -26,7 +28,7 @@ const mobileNavServer = tv({ slots: {
26
28
  "min-h-option gap-2.5 rounded-chip px-2.5 py-option-inset",
27
29
  "font-sans text-callout font-medium text-foreground",
28
30
  "hover:bg-brand-soft active:bg-brand-soft focus-visible:bg-brand-soft",
29
- "aria-[current=true]:font-bold",
31
+ "aria-current:font-bold",
30
32
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&>svg:not([class*='size-'])]:size-4",
31
33
  "[&>svg]:text-foreground-muted",
32
34
  "*:data-[slot=avatar]:size-5 *:data-[slot=avatar]:shrink-0"
@@ -162,21 +164,49 @@ function MobileNavServerItem({ className, id, ref, onClick, onContextMenu, onPoi
162
164
  }
163
165
  /**
164
166
  * The popover above the tab bar, 8px from each side of it and 8px above it,
165
- * as tall as its content and at most 60% of the screen. It stops below the
166
- * top safe area (a notch in a standalone web app): Base UI's collision padding
167
- * takes only pixels, so the height cap subtracts `env(safe-area-inset-top)`.
168
- * The bottom needs none: the tab bar under the popover already pads the
169
- * bottom safe area. A column:
167
+ * as tall as its content and at most 60% of the screen. The one exception:
168
+ * the list always keeps one whole server row, so where the parts after it
169
+ * leave less, the popover grows past 60% as far as that row needs (the
170
+ * floor, measured from its children into `--mobile-nav-server-popover-floor`).
171
+ * Where even that doesn't fit, the whole popover scrolls, the footer and the
172
+ * pages with it: the children sit in a ScrollArea
173
+ * (`data-slot="mobile-nav-server-popover-scroll"`) inside the popup. It stops below the top safe area (a notch in a standalone
174
+ * web app): Base UI's collision padding takes only pixels, so the height cap
175
+ * subtracts `env(safe-area-inset-top)`. The bottom needs none: the tab bar
176
+ * under the popover already pads the bottom safe area. A column:
170
177
  * `MobileNavServerPopoverList` takes the room left and scrolls, and the
171
178
  * destinations and the footer after it stay in view.
179
+ *
180
+ * Focus on open goes to the current server's row (`aria-current="true"` in
181
+ * the list), which the list has already scrolled into view, so focusing it
182
+ * never scrolls the list back to the top. A touch opens it with focus on the
183
+ * popover itself, without scrolling, so no keyboard comes up. `initialFocus`
184
+ * overrides this.
172
185
  */
173
- function MobileNavServerPopoverContent({ anchor, side = "top", align = "center", sideOffset = 8, collisionPadding = 8, className, ...props }) {
186
+ function MobileNavServerPopoverContent({ anchor, side = "top", align = "center", sideOffset = 8, collisionPadding = 8, initialFocus, className, children, ref, ...props }) {
174
187
  const { triggerRef } = useMobileNavServerContext("MobileNavServerPopoverContent");
175
- const { content } = mobileNavServer();
188
+ const { content, scroll } = mobileNavServer();
189
+ const popupRef = useRef(null);
176
190
  const tabBar = useCallback(() => {
177
191
  const trigger = triggerRef.current;
178
192
  return trigger?.closest("[data-slot=mobile-nav-root]") ?? trigger;
179
193
  }, [triggerRef]);
194
+ const setRef = useCallback((node) => {
195
+ popupRef.current = node;
196
+ assignRef(ref, node);
197
+ if (!node) return;
198
+ return () => {
199
+ popupRef.current = null;
200
+ assignRef(ref, null);
201
+ };
202
+ }, [ref]);
203
+ const setScrollRef = useCallback((node) => {
204
+ if (node) return keepOneRow(node);
205
+ }, []);
206
+ const focusCurrent = useCallback((openType) => {
207
+ if (openType === "touch") return true;
208
+ return popupRef.current?.querySelector(CURRENT_ROW) ?? true;
209
+ }, []);
180
210
  return /* @__PURE__ */ jsx(PopoverContent, {
181
211
  "data-slot": "mobile-nav-server-popover-content",
182
212
  anchor: anchor ?? tabBar,
@@ -184,16 +214,115 @@ function MobileNavServerPopoverContent({ anchor, side = "top", align = "center",
184
214
  align,
185
215
  sideOffset,
186
216
  collisionPadding,
217
+ initialFocus: initialFocus ?? focusCurrent,
187
218
  className: content({ className }),
188
- ...props
219
+ ref: setRef,
220
+ ...props,
221
+ children: /* @__PURE__ */ jsx(ScrollArea, {
222
+ "data-slot": "mobile-nav-server-popover-scroll",
223
+ overscroll: "contain",
224
+ tabIndex: -1,
225
+ ref: setScrollRef,
226
+ className: scroll(),
227
+ children
228
+ })
189
229
  });
190
230
  }
231
+ const LIST_SLOT = "mobile-nav-server-popover-list";
232
+ const CURRENT_ROW = `[data-slot=${LIST_SLOT}] [aria-current="true"]`;
233
+ const GUTTERS_SLOT = "scroll-area-gutters";
234
+ /** Padding and border along the block axis. */
235
+ function blockFrame(element) {
236
+ const style = getComputedStyle(element);
237
+ return parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) + parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
238
+ }
239
+ /**
240
+ * Writes the popover's floor onto the popup, its height with the list at its
241
+ * one-row minimum: the list's `min-height` plus the height of every other
242
+ * part in the scroll area, and the popup's and the area's own padding and
243
+ * border. Watched as the parts change.
244
+ */
245
+ function keepOneRow(area) {
246
+ const popup = area.parentElement;
247
+ const measure = () => {
248
+ if (!popup) return;
249
+ let floor = blockFrame(popup) + blockFrame(area);
250
+ for (const child of area.children) {
251
+ if (!(child instanceof HTMLElement) || child.dataset.slot === GUTTERS_SLOT) continue;
252
+ const style = getComputedStyle(child);
253
+ if (style.display === "none" || style.position === "absolute" || style.position === "fixed") continue;
254
+ floor += parseFloat(style.marginTop) + parseFloat(style.marginBottom);
255
+ floor += child.dataset.slot === LIST_SLOT ? parseFloat(style.minHeight) || 0 : child.getBoundingClientRect().height;
256
+ }
257
+ popup.style.setProperty("--mobile-nav-server-popover-floor", `${floor}px`);
258
+ };
259
+ const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
260
+ const watch = () => {
261
+ resize?.disconnect();
262
+ for (const child of area.children) if (child instanceof HTMLElement && child.dataset.slot !== LIST_SLOT) resize?.observe(child);
263
+ measure();
264
+ };
265
+ const mutation = new MutationObserver(watch);
266
+ mutation.observe(area, { childList: true });
267
+ watch();
268
+ return () => {
269
+ resize?.disconnect();
270
+ mutation.disconnect();
271
+ };
272
+ }
273
+ /**
274
+ * Opens the list on the current server: scrolls it, and only it, to the row
275
+ * with `aria-current="true"` at once, with no animation, and again as the
276
+ * list takes its final size, until people scroll or press in it themselves.
277
+ */
278
+ function openOnCurrent(list) {
279
+ let pinned = true;
280
+ const show = () => {
281
+ if (!pinned) return;
282
+ const current = list.querySelector("[aria-current=\"true\"]");
283
+ if (!current) return;
284
+ const offset = parseFloat(getComputedStyle(list).paddingBottom) || 0;
285
+ scrollToElement(list, current, "vertical", {
286
+ block: "nearest",
287
+ offset
288
+ });
289
+ };
290
+ const release = () => {
291
+ pinned = false;
292
+ };
293
+ const events = [
294
+ "pointerdown",
295
+ "touchstart",
296
+ "wheel",
297
+ "keydown"
298
+ ];
299
+ for (const type of events) list.addEventListener(type, release, { passive: true });
300
+ const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(show);
301
+ resize?.observe(list);
302
+ show();
303
+ return () => {
304
+ resize?.disconnect();
305
+ for (const type of events) list.removeEventListener(type, release);
306
+ };
307
+ }
191
308
  /** The server list: takes the room left in the popover and scrolls inside it,
192
- * a ScrollArea (a column with no gap, so its gutters move nothing). */
193
- function MobileNavServerPopoverList({ className, ...props }) {
309
+ * a ScrollArea (a column with no gap, so its gutters move nothing). It is
310
+ * never shorter than one whole two-line row, and it opens scrolled to the
311
+ * current server's row (`aria-current="true"`), with no animation. */
312
+ function MobileNavServerPopoverList({ className, ref, ...props }) {
194
313
  const { list } = mobileNavServer();
314
+ const setRef = useCallback((node) => {
315
+ assignRef(ref, node);
316
+ if (!node) return;
317
+ const stop = openOnCurrent(node);
318
+ return () => {
319
+ stop();
320
+ assignRef(ref, null);
321
+ };
322
+ }, [ref]);
195
323
  return /* @__PURE__ */ jsx(ScrollArea, {
196
324
  "data-slot": "mobile-nav-server-popover-list",
325
+ ref: setRef,
197
326
  overscroll: "contain",
198
327
  tabIndex: -1,
199
328
  ...props,
@@ -28,7 +28,7 @@ export type SegmentedControlProps<T extends string = string> = StyledProps<Radio
28
28
  */
29
29
  variant?: "default" | "on-frame";
30
30
  };
31
- export declare function SegmentedControl<T extends string = string>({ disabled, iconsOnlyBelow, variant, className, ref, ...props }: SegmentedControlProps<T>): import("react").JSX.Element;
31
+ export declare function SegmentedControl<T extends string = string>({ disabled, iconsOnlyBelow: iconsOnlyBelowProp, variant, className, ref, ...props }: SegmentedControlProps<T>): import("react").JSX.Element;
32
32
  export type SegmentedControlItemProps<T extends string = string> = StyledProps<Radio.Root.Props<T>>;
33
33
  export declare function SegmentedControlItem<T extends string = string>({ className, ref, ...props }: SegmentedControlItemProps<T>): import("react").JSX.Element;
34
34
  export type SegmentedControlLabelProps = ComponentPropsWithRef<"span">;
@@ -2,6 +2,7 @@ import { tv } from "../../lib/tv.mjs";
2
2
  import { useMergedRef } from "../../lib/use-merged-ref.mjs";
3
3
  import { useTapActivation } from "../../lib/tap-activation.mjs";
4
4
  import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip/tooltip.mjs";
5
+ import { useInFitToolbar } from "../fit-toolbar/fit-toolbar.mjs";
5
6
  import { createContext, use, useCallback, useLayoutEffect, useRef, useState } from "react";
6
7
  import { jsx, jsxs } from "react/jsx-runtime";
7
8
  import { Radio } from "@base-ui/react/radio";
@@ -51,6 +52,11 @@ const segmentedControl = tv({
51
52
  xl: { root: ["@max-xl:**:data-[slot=segmented-control-item]:relative @max-xl:**:data-[slot=segmented-control-item]:w-(--segment-size) @max-xl:**:data-[slot=segmented-control-item]:px-0", "@max-xl:**:data-[slot=segmented-control-label]:sr-only @max-xl:**:data-[slot=segmented-control-count]:sr-only"] },
52
53
  "2xl": { root: ["@max-2xl:**:data-[slot=segmented-control-item]:relative @max-2xl:**:data-[slot=segmented-control-item]:w-(--segment-size) @max-2xl:**:data-[slot=segmented-control-item]:px-0", "@max-2xl:**:data-[slot=segmented-control-label]:sr-only @max-2xl:**:data-[slot=segmented-control-count]:sr-only"] }
53
54
  },
55
+ inFitToolbar: { true: { root: [
56
+ "shrink-0",
57
+ "data-fit-label:**:data-[slot=segmented-control-item]:relative data-fit-label:**:data-[slot=segmented-control-item]:w-(--segment-size) data-fit-label:**:data-[slot=segmented-control-item]:px-0",
58
+ "data-fit-label:**:data-[slot=segmented-control-label]:sr-only data-fit-label:**:data-[slot=segmented-control-count]:sr-only"
59
+ ] } },
54
60
  iconsOnlyMeasured: { true: { root: ["data-icons-only:**:data-[slot=segmented-control-item]:relative data-icons-only:**:data-[slot=segmented-control-item]:w-(--segment-size) data-icons-only:**:data-[slot=segmented-control-item]:px-0", "data-icons-only:**:data-[slot=segmented-control-label]:sr-only data-icons-only:**:data-[slot=segmented-control-count]:sr-only"] } }
55
61
  }
56
62
  });
@@ -216,11 +222,14 @@ function useIconsOnlyWarning(ref, iconsOnlyBelow) {
216
222
  }
217
223
  }, [ref, iconsOnlyBelow]);
218
224
  }
219
- function SegmentedControl({ disabled = false, iconsOnlyBelow, variant = "default", className, ref, ...props }) {
225
+ function SegmentedControl({ disabled = false, iconsOnlyBelow: iconsOnlyBelowProp, variant = "default", className, ref, ...props }) {
226
+ const inFitToolbar = useInFitToolbar();
227
+ const iconsOnlyBelow = inFitToolbar ? void 0 : iconsOnlyBelowProp;
220
228
  const preset = isIconsOnlyPreset(iconsOnlyBelow);
221
229
  const { root } = segmentedControl({
222
230
  iconsOnlyBelow: preset ? iconsOnlyBelow : void 0,
223
231
  iconsOnlyMeasured: iconsOnlyBelow !== void 0 && !preset ? true : void 0,
232
+ inFitToolbar: inFitToolbar || void 0,
224
233
  variant
225
234
  });
226
235
  const rootRef = useRef(null);
@@ -242,7 +251,7 @@ function SegmentedControl({ disabled = false, iconsOnlyBelow, variant = "default
242
251
  else if (end > track.scrollLeft + track.clientWidth) track.scrollLeft = end - track.clientWidth;
243
252
  }, [overflowing, value]);
244
253
  return /* @__PURE__ */ jsx(SegmentedControlIconsOnlyContext, {
245
- value: iconsOnlyBelow !== void 0,
254
+ value: inFitToolbar || iconsOnlyBelow !== void 0,
246
255
  children: /* @__PURE__ */ jsx(RadioGroup, {
247
256
  ref: setRef,
248
257
  disabled,
@@ -0,0 +1,2 @@
1
+ import { SelectionToolbar, SelectionToolbarAction, SelectionToolbarActionProps, SelectionToolbarActions, SelectionToolbarActionsProps, SelectionToolbarClose, SelectionToolbarCloseProps, SelectionToolbarCount, SelectionToolbarCountProps, SelectionToolbarCountValue, SelectionToolbarCountValueProps, SelectionToolbarDock, SelectionToolbarDockProps, SelectionToolbarHint, SelectionToolbarHintProps, SelectionToolbarItem, SelectionToolbarItemProps, SelectionToolbarMore, SelectionToolbarMoreItems, SelectionToolbarMoreProps, SelectionToolbarProps } from "./selection-toolbar.mjs";
2
+ export { SelectionToolbar, SelectionToolbarAction, type SelectionToolbarActionProps, SelectionToolbarActions, type SelectionToolbarActionsProps, SelectionToolbarClose, type SelectionToolbarCloseProps, SelectionToolbarCount, type SelectionToolbarCountProps, SelectionToolbarCountValue, type SelectionToolbarCountValueProps, SelectionToolbarDock, type SelectionToolbarDockProps, SelectionToolbarHint, type SelectionToolbarHintProps, SelectionToolbarItem, type SelectionToolbarItemProps, SelectionToolbarMore, SelectionToolbarMoreItems, type SelectionToolbarMoreProps, type SelectionToolbarProps };
@@ -0,0 +1,100 @@
1
+ import { ButtonProps } from "../button/button.mjs";
2
+ import "../button/index.mjs";
3
+ import { FitToolbarGroupProps, FitToolbarMoreItems, FitToolbarProps } from "../fit-toolbar/fit-toolbar.mjs";
4
+ import "../fit-toolbar/index.mjs";
5
+ import { ReactElement, ReactNode } from "react";
6
+ import { useRender } from "@base-ui/react/use-render";
7
+ //#region src/components/selection-toolbar/selection-toolbar.d.ts
8
+ export type SelectionToolbarDockProps = useRender.ComponentProps<"div">;
9
+ /**
10
+ * Where the toolbar floats: centered, 12px above the bottom of the list or
11
+ * conversation it sits in (plus the safe area), 20px from its sides. Its
12
+ * parent must be positioned.
13
+ */
14
+ export declare function SelectionToolbarDock({ className, render, ...props }: SelectionToolbarDockProps): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
15
+ export type SelectionToolbarProps = Omit<FitToolbarProps, "wrap" | "orientation">;
16
+ /**
17
+ * The floating toolbar for selected items (messages, tasks): the count, an
18
+ * optional hint, the actions on the selection, More while some are out of
19
+ * the row, then ×. One row at any width: what doesn't fit gives way, measured
20
+ * in the width it floats in, and never wraps. A `role="toolbar"`, one Tab
21
+ * stop with the arrow keys inside: give it an `aria-label`.
22
+ */
23
+ export declare function SelectionToolbar({ className, ...props }: SelectionToolbarProps): import("react").JSX.Element;
24
+ export type SelectionToolbarCountProps = useRender.ComponentProps<"span">;
25
+ /**
26
+ * What the actions act on ("3 selected"), first. Announced when it changes.
27
+ * Put the number in `SelectionToolbarCountValue`.
28
+ */
29
+ export declare function SelectionToolbarCount({ className, render, ...props }: SelectionToolbarCountProps): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
30
+ export type SelectionToolbarCountValueProps = useRender.ComponentProps<"span">;
31
+ /** The number in the count, in Space Mono. */
32
+ export declare function SelectionToolbarCountValue({ className, render, ...props }: SelectionToolbarCountValueProps): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
33
+ export type SelectionToolbarHintProps = useRender.ComponentProps<"span">;
34
+ /**
35
+ * A short line after the count, such as "1 will be skipped". The first to
36
+ * give way: it is then read by screen readers only.
37
+ */
38
+ export declare function SelectionToolbarHint({ className, render, ...props }: SelectionToolbarHintProps): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
39
+ export type SelectionToolbarItemProps = {
40
+ /** The control, such as `<Checkbox />`. It keeps its own role and keys. */
41
+ children: ReactElement<Record<string, unknown>>;
42
+ };
43
+ /**
44
+ * Any other control of the toolbar, such as a thread's select-all checkbox
45
+ * before the count, so the arrow keys reach it. It takes the toolbar's focus
46
+ * handling only: the control keeps its role and its own keys.
47
+ */
48
+ export declare function SelectionToolbarItem({ children }: SelectionToolbarItemProps): import("react").JSX.Element;
49
+ export type SelectionToolbarActionsProps = Omit<FitToolbarGroupProps, "wrapOrder" | "firstLine" | "collapseOrder" | "collapsed">;
50
+ /** The actions on the selection, in one row. */
51
+ export declare function SelectionToolbarActions({ className, ...props }: SelectionToolbarActionsProps): import("react").JSX.Element;
52
+ export type SelectionToolbarActionProps = Omit<ButtonProps, "variant" | "size" | "children"> & {
53
+ /** The action's 14px icon. */
54
+ icon: ReactNode;
55
+ /** The label. While only the icon shows, the label is its accessible name and its tooltip. */
56
+ children: ReactNode;
57
+ /** Moves into More before the labels give way, the last first. */
58
+ secondary?: boolean;
59
+ } & ({
60
+ menu?: false;
61
+ /**
62
+ * What the action is in More's menu while it is there. By default a
63
+ * `DropdownMenuItem` with its icon and label that calls its `onClick`.
64
+ */
65
+ moreItem?: ReactNode;
66
+ } | {
67
+ /** The action opens a menu: a 12px chevron follows the label. Open the menu upward. */
68
+ menu: true;
69
+ /**
70
+ * Required: its menu in More's (a `DropdownMenuSubmenu` with the same
71
+ * choices). Its `onClick` only opens its own menu, so it has no
72
+ * default, and without one it would vanish into More.
73
+ */
74
+ moreItem: ReactNode;
75
+ });
76
+ /**
77
+ * One action on the selection: a 14px icon and a label, an `md` ghost button
78
+ * with a mouse on a desktop, a 44px bar button showing its icon alone on a
79
+ * phone and a touch screen.
80
+ */
81
+ export declare function SelectionToolbarAction({ icon, children, secondary, menu, moreItem, className, onClick, disabled, ...props }: SelectionToolbarActionProps): import("react").JSX.Element;
82
+ export type SelectionToolbarMoreProps = Omit<ButtonProps, "variant" | "size" | "aria-label"> & {
83
+ /** Its accessible name, such as "More actions". */
84
+ "aria-label": string;
85
+ };
86
+ /**
87
+ * More, after the actions, shown only while some are in it. Render it as a
88
+ * `DropdownMenuTrigger` whose menu opens upward (`side="top"`), with
89
+ * `SelectionToolbarMoreItems` in the menu.
90
+ */
91
+ export declare function SelectionToolbarMore({ className, children, ...props }: SelectionToolbarMoreProps): import("react").JSX.Element;
92
+ /** The actions in More, in their order, each as its `moreItem`: put it in More's menu. */
93
+ export declare const SelectionToolbarMoreItems: typeof FitToolbarMoreItems;
94
+ export type SelectionToolbarCloseProps = Omit<ButtonProps, "variant" | "size" | "aria-label"> & {
95
+ /** Its accessible name, such as "Cancel". */
96
+ "aria-label": string;
97
+ };
98
+ /** ×, last: ends the selection, as Escape does. */
99
+ export declare function SelectionToolbarClose({ className, children, ...props }: SelectionToolbarCloseProps): import("react").JSX.Element;
100
+ //#endregion
@@ -0,0 +1,217 @@
1
+ import { Button } from "../button/button.mjs";
2
+ import { DropdownMenuItem } from "../dropdown-menu/dropdown-menu.mjs";
3
+ import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip/tooltip.mjs";
4
+ import { FitToolbar, FitToolbarGroup, FitToolbarMoreItems, useFitItem, useFitMore } from "../fit-toolbar/fit-toolbar.mjs";
5
+ import { selectionToolbar } from "./selection-toolbar.recipe.mjs";
6
+ import { ChevronDown, MoreHorizontal, X } from "lucide-react";
7
+ import { cloneElement, useCallback } from "react";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ import { mergeProps } from "@base-ui/react/merge-props";
10
+ import { useRender } from "@base-ui/react/use-render";
11
+ import { Toolbar } from "@base-ui/react/toolbar";
12
+ //#region src/components/selection-toolbar/selection-toolbar.tsx
13
+ /**
14
+ * The order things give way in as the toolbar runs out of room: the hint,
15
+ * then the secondary actions into More (the last first), then the labels (the
16
+ * last first, so the first action's goes last), then the other actions into
17
+ * More, the last first. The count and × never do.
18
+ */
19
+ const HINT_STEP = 10;
20
+ const SECONDARY_STEP = 20;
21
+ const LABEL_STEP = 30;
22
+ const MORE_STEP = 40;
23
+ /**
24
+ * Where the toolbar floats: centered, 12px above the bottom of the list or
25
+ * conversation it sits in (plus the safe area), 20px from its sides. Its
26
+ * parent must be positioned.
27
+ */
28
+ function SelectionToolbarDock({ className, render, ...props }) {
29
+ const { dock } = selectionToolbar();
30
+ const defaultProps = { className: dock({ className }) };
31
+ return useRender({
32
+ defaultTagName: "div",
33
+ render,
34
+ props: mergeProps(defaultProps, props),
35
+ state: { slot: "selection-toolbar-dock" }
36
+ });
37
+ }
38
+ /**
39
+ * The floating toolbar for selected items (messages, tasks): the count, an
40
+ * optional hint, the actions on the selection, More while some are out of
41
+ * the row, then ×. One row at any width: what doesn't fit gives way, measured
42
+ * in the width it floats in, and never wraps. A `role="toolbar"`, one Tab
43
+ * stop with the arrow keys inside: give it an `aria-label`.
44
+ */
45
+ function SelectionToolbar({ className, ...props }) {
46
+ const { root } = selectionToolbar();
47
+ return /* @__PURE__ */ jsx(FitToolbar, {
48
+ "data-slot": "selection-toolbar",
49
+ className: root({ className }),
50
+ ...props
51
+ });
52
+ }
53
+ /**
54
+ * What the actions act on ("3 selected"), first. Announced when it changes.
55
+ * Put the number in `SelectionToolbarCountValue`.
56
+ */
57
+ function SelectionToolbarCount({ className, render, ...props }) {
58
+ const { count } = selectionToolbar();
59
+ const defaultProps = {
60
+ "aria-live": "polite",
61
+ "aria-atomic": true,
62
+ className: count({ className })
63
+ };
64
+ return useRender({
65
+ defaultTagName: "span",
66
+ render,
67
+ props: mergeProps(defaultProps, props),
68
+ state: { slot: "selection-toolbar-count" }
69
+ });
70
+ }
71
+ /** The number in the count, in Space Mono. */
72
+ function SelectionToolbarCountValue({ className, render, ...props }) {
73
+ const { countValue } = selectionToolbar();
74
+ const defaultProps = { className: countValue({ className }) };
75
+ return useRender({
76
+ defaultTagName: "span",
77
+ render,
78
+ props: mergeProps(defaultProps, props),
79
+ state: { slot: "selection-toolbar-count-value" }
80
+ });
81
+ }
82
+ /**
83
+ * A short line after the count, such as "1 will be skipped". The first to
84
+ * give way: it is then read by screen readers only.
85
+ */
86
+ function SelectionToolbarHint({ className, render, ...props }) {
87
+ const { hint } = selectionToolbar();
88
+ const item = useFitItem({ label: HINT_STEP });
89
+ const defaultProps = {
90
+ className: hint({ className }),
91
+ ...item.props
92
+ };
93
+ return useRender({
94
+ defaultTagName: "span",
95
+ render,
96
+ props: mergeProps(defaultProps, props),
97
+ state: { slot: "selection-toolbar-hint" }
98
+ });
99
+ }
100
+ /**
101
+ * Any other control of the toolbar, such as a thread's select-all checkbox
102
+ * before the count, so the arrow keys reach it. It takes the toolbar's focus
103
+ * handling only: the control keeps its role and its own keys.
104
+ */
105
+ function SelectionToolbarItem({ children }) {
106
+ const renderItem = useCallback((itemProps) => {
107
+ const { role: _role, type: _type, onClick: _onClick, onKeyDown: _onKeyDown, onKeyUp: _onKeyUp, onMouseDown: _onMouseDown, onPointerDown: _onPointerDown, "aria-disabled": _ariaDisabled, ...composite } = itemProps;
108
+ return cloneElement(children, mergeProps(children.props, {
109
+ ...composite,
110
+ "data-selection-toolbar-item": ""
111
+ }));
112
+ }, [children]);
113
+ return /* @__PURE__ */ jsx(Toolbar.Button, {
114
+ nativeButton: false,
115
+ render: renderItem
116
+ });
117
+ }
118
+ /** The actions on the selection, in one row. */
119
+ function SelectionToolbarActions({ className, ...props }) {
120
+ const { actions } = selectionToolbar();
121
+ return /* @__PURE__ */ jsx(FitToolbarGroup, {
122
+ "data-slot": "selection-toolbar-actions",
123
+ className: actions({ className }),
124
+ ...props
125
+ });
126
+ }
127
+ let warnedMenuWithoutMoreItem = false;
128
+ function warnMenuWithoutMoreItem() {
129
+ if (warnedMenuWithoutMoreItem) return;
130
+ warnedMenuWithoutMoreItem = true;
131
+ console.warn("raft-ui: a SelectionToolbarAction with `menu` has no `moreItem`; it would vanish when it moves into More. Give it its menu as a DropdownMenuSubmenu.");
132
+ }
133
+ /**
134
+ * One action on the selection: a 14px icon and a label, an `md` ghost button
135
+ * with a mouse on a desktop, a 44px bar button showing its icon alone on a
136
+ * phone and a touch screen.
137
+ */
138
+ function SelectionToolbarAction({ icon, children, secondary = false, menu = false, moreItem, className, onClick, disabled, ...props }) {
139
+ const { action, actionLabel, actionChevron } = selectionToolbar({ menu });
140
+ if (process.env.NODE_ENV !== "production" && menu && moreItem === void 0) warnMenuWithoutMoreItem();
141
+ const item = useFitItem({
142
+ label: LABEL_STEP,
143
+ more: secondary ? SECONDARY_STEP : MORE_STEP,
144
+ moreItem: moreItem !== void 0 ? moreItem : menu ? null : /* @__PURE__ */ jsxs(DropdownMenuItem, {
145
+ disabled,
146
+ onClick,
147
+ children: [icon, children]
148
+ })
149
+ });
150
+ return /* @__PURE__ */ jsxs(Tooltip, {
151
+ disabled: !item.taken("label"),
152
+ children: [/* @__PURE__ */ jsxs(TooltipTrigger, {
153
+ "data-slot": "button",
154
+ render: /* @__PURE__ */ jsx(Toolbar.Button, { render: /* @__PURE__ */ jsx(Button, {
155
+ "data-selection-toolbar-action": "",
156
+ "data-secondary": secondary ? "" : void 0,
157
+ variant: "ghost",
158
+ size: "md",
159
+ className: action({ className }),
160
+ onClick,
161
+ disabled,
162
+ ...item.props,
163
+ ...props
164
+ }) }),
165
+ children: [
166
+ icon,
167
+ /* @__PURE__ */ jsx("span", {
168
+ "data-slot": "selection-toolbar-action-label",
169
+ className: actionLabel(),
170
+ children
171
+ }),
172
+ menu ? /* @__PURE__ */ jsx(ChevronDown, {
173
+ "aria-hidden": true,
174
+ className: actionChevron()
175
+ }) : null
176
+ ]
177
+ }), /* @__PURE__ */ jsx(TooltipContent, { children })]
178
+ });
179
+ }
180
+ /**
181
+ * More, after the actions, shown only while some are in it. Render it as a
182
+ * `DropdownMenuTrigger` whose menu opens upward (`side="top"`), with
183
+ * `SelectionToolbarMoreItems` in the menu.
184
+ */
185
+ function SelectionToolbarMore({ className, children, ...props }) {
186
+ const { more } = selectionToolbar();
187
+ const fit = useFitMore();
188
+ return /* @__PURE__ */ jsx(Toolbar.Button, {
189
+ render: /* @__PURE__ */ jsx(Button, {
190
+ "data-selection-toolbar-more": "",
191
+ variant: "ghost",
192
+ size: "md",
193
+ className: more({ className }),
194
+ ...fit.props,
195
+ ...props
196
+ }),
197
+ children: children ?? /* @__PURE__ */ jsx(MoreHorizontal, {})
198
+ });
199
+ }
200
+ /** The actions in More, in their order, each as its `moreItem`: put it in More's menu. */
201
+ const SelectionToolbarMoreItems = FitToolbarMoreItems;
202
+ /** ×, last: ends the selection, as Escape does. */
203
+ function SelectionToolbarClose({ className, children, ...props }) {
204
+ const { close } = selectionToolbar();
205
+ return /* @__PURE__ */ jsx(Toolbar.Button, {
206
+ render: /* @__PURE__ */ jsx(Button, {
207
+ "data-selection-toolbar-close": "",
208
+ variant: "ghost",
209
+ size: "icon-md",
210
+ className: close({ className }),
211
+ ...props
212
+ }),
213
+ children: children ?? /* @__PURE__ */ jsx(X, {})
214
+ });
215
+ }
216
+ //#endregion
217
+ export { SelectionToolbar, SelectionToolbarAction, SelectionToolbarActions, SelectionToolbarClose, SelectionToolbarCount, SelectionToolbarCountValue, SelectionToolbarDock, SelectionToolbarHint, SelectionToolbarItem, SelectionToolbarMore, SelectionToolbarMoreItems };
@@ -0,0 +1,42 @@
1
+ import { tv } from "../../lib/tv.mjs";
2
+ //#region src/components/selection-toolbar/selection-toolbar.recipe.ts
3
+ const selectionToolbar = tv({
4
+ slots: {
5
+ dock: [
6
+ "pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-center",
7
+ "px-5 pb-[calc(--spacing(3)+env(safe-area-inset-bottom))]",
8
+ "*:pointer-events-auto"
9
+ ],
10
+ root: [
11
+ "w-fit gap-0 md:pointer-fine:gap-1",
12
+ "h-(--header-height) px-(--header-air) py-0",
13
+ "rounded-container bg-layer-popover ring-1 ring-line-seam shadow-float",
14
+ "**:data-[slot=button]:after:inset-x-[-1.5px] md:pointer-fine:**:data-[slot=button]:after:inset-x-[-3px]",
15
+ "*:data-[slot=button]:first:after:-left-[(var(--header-air)+1.5px)] *:data-[slot=button]:last:after:-right-[(var(--header-air)+1.5px)]"
16
+ ],
17
+ count: ["shrink-0 pr-0 pl-2 md:pointer-fine:px-2", "whitespace-nowrap text-callout font-bold text-foreground tabular-nums"],
18
+ countValue: ["font-mono"],
19
+ hint: ["shrink-0 pr-1 whitespace-nowrap text-caption text-foreground-hint", "data-fit-label:sr-only"],
20
+ actions: ["gap-0 md:pointer-fine:gap-1"],
21
+ action: [
22
+ "[&_svg:not([class*='size-'])]:size-3.5",
23
+ "size-(--bar-button-size) justify-center px-0 after:inset-y-[-1.5px]",
24
+ "rounded-(--bar-button-radius) md:pointer-fine:rounded-control",
25
+ "md:pointer-fine:w-auto md:pointer-fine:pr-3 md:pointer-fine:pl-2.5",
26
+ "md:pointer-fine:data-fit-label:w-(--control-height) md:pointer-fine:data-fit-label:px-0",
27
+ "data-fit-more:hidden"
28
+ ],
29
+ actionLabel: ["sr-only md:pointer-fine:not-sr-only md:pointer-fine:in-data-fit-label:sr-only"],
30
+ actionChevron: ["-ml-1 hidden size-3 md:pointer-fine:block md:pointer-fine:in-data-fit-label:hidden"],
31
+ more: [
32
+ "hidden data-fit-shown:inline-flex [&_svg:not([class*='size-'])]:size-3.5",
33
+ "size-(--bar-button-size) px-0 after:inset-y-[-1.5px]",
34
+ "rounded-(--bar-button-radius) md:pointer-fine:rounded-control",
35
+ "md:pointer-fine:w-(--control-height)"
36
+ ],
37
+ close: ["size-(--bar-button-size) after:inset-y-[-1.5px]", "rounded-(--bar-button-radius) md:pointer-fine:rounded-control"]
38
+ },
39
+ variants: { menu: { true: { action: "md:pointer-fine:pr-2.5" } } }
40
+ });
41
+ //#endregion
42
+ export { selectionToolbar };