@botiverse/raft-ui 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,31 @@
1
+ ## [0.28.0](https://github.com/botiverse/raft-design/compare/v0.27.0...v0.28.0) (2026-10-23)
2
+
3
+ ### Design System
4
+
5
+ - **Toasts: one at a time, no Dismiss, above the composer on a phone**: a toast shows alone; a new one replaces it at once and the replaced one is closed, not queued. It has no Dismiss: it closes by itself after 5 seconds, or 8 with an action or as an error, and can be swiped away or closed with Esc. It stays at the bottom center: at the bottom of the window on a desktop, and on a phone just above the composer, the tab bar or the keyboard, which the app sets with `--toast-viewport-bottom`. ([#675](https://github.com/botiverse/raft-design/pull/675))
6
+ - Why: stacked toasts and a × on each one asked people to manage messages that were only receipts; at the very bottom of a phone a toast sat under the composer and the keyboard (decided with stdrc).
7
+ - Affects: a rule for products; raft-ui's Toast defaults.
8
+ - Docs: https://raft-design.botiverse.build/components/toast/#where-it-appears
9
+
10
+ - **Pull to refresh looks like Android's own on Android**: on Android web the indicator is Material 3's: a small disc with a shadow slides down over the list, which stays still; inside, a progress arc with an arrowhead grows with the pull, faint until the pull is armed and faint again when it is pulled back (disarmed); while refreshing the arc turns; at the end the disc scales away. iOS and everything else keep the Spinner. The disc is the one round surface besides switches, radios and grab handles, and its turning arc the one other looping animation. ([#765](https://github.com/botiverse/raft-design/pull/765))
11
+ - Why: a pull on Android should look the way it does in every Android app (decided by stdrc); armed and disarmed now look different.
12
+ - Affects: `PullToRefresh` (wip) on Android; shape, motion and navigation rules record the exception.
13
+ - Docs: https://raft-design.botiverse.build/components/pull-to-refresh/#on-android
14
+
15
+ ### Changed
16
+
17
+ - **Toast defaults** ([#675](https://github.com/botiverse/raft-design/pull/675)):
18
+ - `limit` is 1 (was 3); a newer toast replaces the older and closes it.
19
+ - The command API shows `ToastClose` only with `dismissible: true`.
20
+ - `actionTimeout` (8s) applies to toasts with an action and to errors; `timeout` stays 5s.
21
+ - Bottom placements sit 16px above `max(var(--toast-viewport-bottom, 0px), env(safe-area-inset-bottom))`; `top-center` takes `--toast-viewport-top`. A toast swipes away toward the edge it came from.
22
+ - Migration: an app that passed its own `placement` or drew its own close can drop them; set `--toast-viewport-bottom` on a phone (from `visualViewport` while the keyboard is up on iOS).
23
+
1
24
  ## [0.27.0](https://github.com/botiverse/raft-design/compare/v0.26.0...v0.27.0) (2026-10-23)
2
25
 
3
26
  ### Design System
4
27
 
5
- - **Refreshing a snapshot page by pulling**: on a phone, a page that shows a snapshot (Activity, Tasks, Members, Computers) refreshes when pulled down past 100px; while it refreshes the indicator holds at 60px, and it snaps back in 300ms without overshoot. Realtime pages (a conversation) are never pulled. On iOS a pull that passed the trigger stays armed; on Android, as in Material 3, pulling back above it takes it back, and crossing it gives a short vibration. The indicator is the Spinner on every platform. Every such page also reloads when it is opened, so the gesture is never the only way. `PullToRefresh` is experimental, from `@botiverse/raft-ui/wip`. ([#743](https://github.com/botiverse/raft-design/pull/743))
28
+ - **Refreshing a snapshot page by pulling**: on a phone, a page that shows a snapshot and doesn't update itself (Saved, search results, a conversation's files, the lists in Settings) refreshes when pulled down past 100px; while it refreshes the indicator holds at 60px, and it snaps back in 300ms without overshoot. Pages fed by realtime updates (conversations, Home, Activity, Tasks, Members, Computers) are never pulled: they stay current on their own. On iOS a pull that passed the trigger stays armed; on Android, as in Material 3, pulling back above it takes it back, and crossing it gives a short vibration. The indicator is the Spinner on every platform. Every such page also reloads when it is opened, so the gesture is never the only way. `PullToRefresh` is experimental, from `@botiverse/raft-ui/wip`. ([#743](https://github.com/botiverse/raft-design/pull/743))
6
29
  - Why: on a phone there was no way to ask a snapshot page for what is new without leaving it; the numbers match Raft's iOS app so the two feel the same.
7
30
  - Affects: a rule for products; raft-ui adds `PullToRefresh` (wip).
8
31
  - Docs: https://raft-design.botiverse.build/patterns/navigation/#refreshing
package/dist/index.d.mts CHANGED
@@ -3291,16 +3291,16 @@ export declare function NotificationCenterEmptyState(props: NotificationCenterEm
3291
3291
  declare const toastStyles: import("tailwind-variants/lite").TVReturnType<{
3292
3292
  placement: {
3293
3293
  "bottom-center": {
3294
- viewport: string;
3294
+ viewport: string[];
3295
3295
  };
3296
3296
  "bottom-right": {
3297
- viewport: string;
3297
+ viewport: string[];
3298
3298
  };
3299
3299
  "top-center": {
3300
- viewport: string;
3300
+ viewport: string[];
3301
3301
  };
3302
3302
  "top-right": {
3303
- viewport: string;
3303
+ viewport: string[];
3304
3304
  };
3305
3305
  none: {
3306
3306
  viewport: string;
@@ -3338,16 +3338,16 @@ declare const toastStyles: import("tailwind-variants/lite").TVReturnType<{
3338
3338
  }, undefined, {
3339
3339
  placement: {
3340
3340
  "bottom-center": {
3341
- viewport: string;
3341
+ viewport: string[];
3342
3342
  };
3343
3343
  "bottom-right": {
3344
- viewport: string;
3344
+ viewport: string[];
3345
3345
  };
3346
3346
  "top-center": {
3347
- viewport: string;
3347
+ viewport: string[];
3348
3348
  };
3349
3349
  "top-right": {
3350
- viewport: string;
3350
+ viewport: string[];
3351
3351
  };
3352
3352
  none: {
3353
3353
  viewport: string;
@@ -3385,16 +3385,16 @@ declare const toastStyles: import("tailwind-variants/lite").TVReturnType<{
3385
3385
  }, import("tailwind-variants/lite").TVReturnTypeLike<{
3386
3386
  placement: {
3387
3387
  "bottom-center": {
3388
- viewport: string;
3388
+ viewport: string[];
3389
3389
  };
3390
3390
  "bottom-right": {
3391
- viewport: string;
3391
+ viewport: string[];
3392
3392
  };
3393
3393
  "top-center": {
3394
- viewport: string;
3394
+ viewport: string[];
3395
3395
  };
3396
3396
  "top-right": {
3397
- viewport: string;
3397
+ viewport: string[];
3398
3398
  };
3399
3399
  none: {
3400
3400
  viewport: string;
@@ -3448,6 +3448,10 @@ type ToastCommandOptions<Data extends object = ToastData> = Omit<Partial<ToastOb
3448
3448
  onClick?: ComponentPropsWithRef<"button">["onClick"];
3449
3449
  };
3450
3450
  contentClassName?: string;
3451
+ /**
3452
+ * Show `ToastClose` (Dismiss). Defaults to `false`: every toast closes by
3453
+ * itself, swipes away and closes with Esc.
3454
+ */
3451
3455
  dismissible?: boolean;
3452
3456
  /** Show the intent's icon in `ToastIcon`. Defaults to `true`. */
3453
3457
  icon?: boolean;
@@ -3472,13 +3476,19 @@ export declare const toast: {
3472
3476
  dismiss(id?: string): void;
3473
3477
  };
3474
3478
  type ToastProviderProps = Toast.Provider.Props & {
3479
+ /**
3480
+ * How long a toast from the `toast` command API stays when it has an action
3481
+ * or is an `error`, in ms. Defaults to 8000, or to `timeout` when that is
3482
+ * longer, and to 0 when `timeout` is 0.
3483
+ */
3484
+ actionTimeout?: number;
3475
3485
  portalContainer?: Toast.Portal.Props["container"];
3476
3486
  renderViewport?: boolean;
3477
3487
  viewportClassName?: string;
3478
3488
  viewportPlacement?: ToastViewportProps["placement"];
3479
3489
  viewportStrategy?: ToastViewportProps["strategy"];
3480
3490
  };
3481
- export declare function ToastProvider({ children, portalContainer, renderViewport, toastManager, timeout, viewportClassName, viewportPlacement, viewportStrategy, ...props }: ToastProviderProps): import("react").JSX.Element;
3491
+ export declare function ToastProvider({ actionTimeout, children, limit, portalContainer, renderViewport, toastManager, timeout, viewportClassName, viewportPlacement, viewportStrategy, ...props }: ToastProviderProps): import("react").JSX.Element;
3482
3492
  type ToastPortalProps = Toast.Portal.Props;
3483
3493
  export declare function ToastPortal(props: ToastPortalProps): import("react").JSX.Element;
3484
3494
  type ToastViewportProps = StyledProps<Toast.Viewport.Props> & Pick<VariantProps<typeof toastStyles>, "placement" | "strategy">;
@@ -3486,7 +3496,13 @@ export declare function ToastViewport({ placement, strategy, className, ...props
3486
3496
  type ToastPositionerProps = StyledProps<Toast.Positioner.Props>;
3487
3497
  export declare function ToastPositioner({ className, ...props }: ToastPositionerProps): import("react").JSX.Element;
3488
3498
  type ToastRootProps = StyledProps<Toast.Root.Props> & Pick<VariantProps<typeof toastStyles>, "layout">;
3489
- export declare function ToastRoot({ layout, className, toast: toastObject, ...props }: ToastRootProps): import("react").JSX.Element;
3499
+ /**
3500
+ * A toast pushed past the provider's `limit` by a newer one is closed, not
3501
+ * held back: toasts replace each other and never queue, so an older toast
3502
+ * can't come back when the newer one goes. Its swipe direction follows the
3503
+ * viewport's edge unless `swipeDirection` is passed.
3504
+ */
3505
+ export declare function ToastRoot({ layout, className, swipeDirection, toast: toastObject, ...props }: ToastRootProps): import("react").JSX.Element;
3490
3506
  type ToastContentProps = StyledProps<Toast.Content.Props> & {
3491
3507
  ref?: Ref<HTMLDivElement>;
3492
3508
  };
package/dist/index.mjs CHANGED
@@ -10143,10 +10143,10 @@ const toastStyles = tv({
10143
10143
  },
10144
10144
  variants: {
10145
10145
  placement: {
10146
- "bottom-center": { viewport: "bottom-4 left-1/2 -translate-x-1/2" },
10147
- "bottom-right": { viewport: "right-4 bottom-4" },
10148
- "top-center": { viewport: "top-4 left-1/2 -translate-x-1/2" },
10149
- "top-right": { viewport: "top-4 right-4" },
10146
+ "bottom-center": { viewport: ["bottom-[calc(max(var(--toast-viewport-bottom,0px),env(safe-area-inset-bottom))+1rem)]", "left-1/2 -translate-x-1/2"] },
10147
+ "bottom-right": { viewport: ["bottom-[calc(max(var(--toast-viewport-bottom,0px),env(safe-area-inset-bottom))+1rem)]", "right-4"] },
10148
+ "top-center": { viewport: ["top-[calc(max(var(--toast-viewport-top,0px),env(safe-area-inset-top))+1rem)]", "left-1/2 -translate-x-1/2"] },
10149
+ "top-right": { viewport: ["top-[calc(max(var(--toast-viewport-top,0px),env(safe-area-inset-top))+1rem)]", "right-4"] },
10150
10150
  none: { viewport: "contents" }
10151
10151
  },
10152
10152
  strategy: {
@@ -10215,7 +10215,22 @@ const toastProgressStyle = /* @__PURE__ */ jsx("style", {
10215
10215
  `
10216
10216
  });
10217
10217
  const DEFAULT_TOAST_TIMEOUT_MS = 5e3;
10218
+ const DEFAULT_TOAST_ACTION_TIMEOUT_MS = 8e3;
10218
10219
  const ToastProviderConfigContext = createContext({ timeout: DEFAULT_TOAST_TIMEOUT_MS });
10220
+ const ToastPlacementContext = createContext(void 0);
10221
+ const SWIPE_DIRECTION_BY_EDGE = {
10222
+ top: ["up"],
10223
+ bottom: ["down", "right"]
10224
+ };
10225
+ function getSwipeDirection(placement) {
10226
+ if (placement?.startsWith("top-")) return [...SWIPE_DIRECTION_BY_EDGE.top];
10227
+ if (placement?.startsWith("bottom-")) return [...SWIPE_DIRECTION_BY_EDGE.bottom];
10228
+ }
10229
+ function resolveActionTimeout(timeout, actionTimeout) {
10230
+ if (actionTimeout !== void 0) return actionTimeout;
10231
+ return timeout > 0 ? Math.max(timeout, DEFAULT_TOAST_ACTION_TIMEOUT_MS) : 0;
10232
+ }
10233
+ const defaultManagerTiming = { actionTimeout: DEFAULT_TOAST_ACTION_TIMEOUT_MS };
10219
10234
  const toastAction = tv({ slots: {
10220
10235
  root: [
10221
10236
  "relative order-2 inline-flex shrink-0 items-center justify-center whitespace-nowrap select-none",
@@ -10236,9 +10251,11 @@ const createToastManager = Toast.createToastManager;
10236
10251
  const useToastManager = Toast.useToastManager;
10237
10252
  const defaultToastManager = createToastManager();
10238
10253
  function addDefaultToast(intent, title, options = {}) {
10239
- const { action, contentClassName, dismissible, icon, leading, priority, progress, ...toastOptions } = options;
10254
+ const { action, contentClassName, dismissible, icon, leading, priority, progress, timeout, ...toastOptions } = options;
10255
+ const needsTimeToAct = Boolean(action) || intent === "error";
10240
10256
  return defaultToastManager.add({
10241
10257
  ...toastOptions,
10258
+ timeout: timeout ?? (needsTimeToAct ? defaultManagerTiming.actionTimeout : void 0),
10242
10259
  actionProps: action ? {
10243
10260
  children: action.label,
10244
10261
  onClick: action.onClick
@@ -10281,13 +10298,22 @@ function getToastTone(type) {
10281
10298
  if (type === "success" || type === "warning" || type === "error") return type;
10282
10299
  return "info";
10283
10300
  }
10284
- function ToastProvider({ children, portalContainer, renderViewport = true, toastManager = defaultToastManager, timeout = DEFAULT_TOAST_TIMEOUT_MS, viewportClassName, viewportPlacement = "bottom-center", viewportStrategy = "fixed", ...props }) {
10301
+ function ToastProvider({ actionTimeout, children, limit = 1, portalContainer, renderViewport = true, toastManager = defaultToastManager, timeout = DEFAULT_TOAST_TIMEOUT_MS, viewportClassName, viewportPlacement = "bottom-center", viewportStrategy = "fixed", ...props }) {
10285
10302
  const config = useMemo(() => ({ timeout }), [timeout]);
10303
+ const resolvedActionTimeout = resolveActionTimeout(timeout, actionTimeout);
10304
+ useLayoutEffect(() => {
10305
+ if (toastManager !== defaultToastManager) return;
10306
+ defaultManagerTiming.actionTimeout = resolvedActionTimeout;
10307
+ return () => {
10308
+ defaultManagerTiming.actionTimeout = DEFAULT_TOAST_ACTION_TIMEOUT_MS;
10309
+ };
10310
+ }, [resolvedActionTimeout, toastManager]);
10286
10311
  return /* @__PURE__ */ jsx(ToastProviderConfigContext.Provider, {
10287
10312
  value: config,
10288
10313
  children: /* @__PURE__ */ jsxs(Toast.Provider, {
10289
10314
  toastManager,
10290
10315
  timeout,
10316
+ limit,
10291
10317
  ...props,
10292
10318
  children: [children, renderViewport ? /* @__PURE__ */ jsx(DefaultToastLayer, {
10293
10319
  placement: viewportPlacement,
@@ -10309,11 +10335,14 @@ function ToastViewport({ placement = "bottom-center", strategy = "fixed", classN
10309
10335
  placement,
10310
10336
  strategy
10311
10337
  });
10312
- return /* @__PURE__ */ jsx(Toast.Viewport, {
10313
- "data-slot": "toast-viewport",
10314
- "data-toast-placement": placement,
10315
- className: viewport({ className }),
10316
- ...props
10338
+ return /* @__PURE__ */ jsx(ToastPlacementContext.Provider, {
10339
+ value: placement,
10340
+ children: /* @__PURE__ */ jsx(Toast.Viewport, {
10341
+ "data-slot": "toast-viewport",
10342
+ "data-toast-placement": placement,
10343
+ className: viewport({ className }),
10344
+ ...props
10345
+ })
10317
10346
  });
10318
10347
  }
10319
10348
  function ToastPositioner({ className, ...props }) {
@@ -10324,11 +10353,28 @@ function ToastPositioner({ className, ...props }) {
10324
10353
  ...props
10325
10354
  });
10326
10355
  }
10327
- function ToastRoot({ layout = "stacked", className, toast: toastObject, ...props }) {
10356
+ /**
10357
+ * A toast pushed past the provider's `limit` by a newer one is closed, not
10358
+ * held back: toasts replace each other and never queue, so an older toast
10359
+ * can't come back when the newer one goes. Its swipe direction follows the
10360
+ * viewport's edge unless `swipeDirection` is passed.
10361
+ */
10362
+ function ToastRoot({ layout = "stacked", className, swipeDirection, toast: toastObject, ...props }) {
10328
10363
  const { root } = toastStyles({ layout });
10364
+ const placement = useContext(ToastPlacementContext);
10365
+ const { close } = useToastManager();
10366
+ const replaced = toastObject.limited === true && toastObject.transitionStatus !== "ending";
10367
+ useLayoutEffect(() => {
10368
+ if (replaced) close(toastObject.id);
10369
+ }, [
10370
+ close,
10371
+ replaced,
10372
+ toastObject.id
10373
+ ]);
10329
10374
  return /* @__PURE__ */ jsx(Toast.Root, {
10330
10375
  "data-slot": "toast",
10331
10376
  "data-toast-type": toastObject.type ?? "info",
10377
+ swipeDirection: swipeDirection ?? getSwipeDirection(placement),
10332
10378
  toast: toastObject,
10333
10379
  className: root({ className }),
10334
10380
  ...props
@@ -10504,29 +10550,24 @@ function DefaultToastLayer({ placement, portalContainer, strategy, viewportClass
10504
10550
  placement,
10505
10551
  strategy,
10506
10552
  className: viewportClassName,
10507
- children: /* @__PURE__ */ jsx(DefaultToastList, { placement })
10553
+ children: /* @__PURE__ */ jsx(DefaultToastList, {})
10508
10554
  })
10509
10555
  });
10510
10556
  }
10511
- function DefaultToastList({ placement }) {
10557
+ function DefaultToastList() {
10512
10558
  const { toasts } = useToastManager();
10513
- return toasts.map((toastObject) => /* @__PURE__ */ jsx(DefaultToastItem, {
10514
- placement,
10515
- toast: toastObject
10516
- }, toastObject.id));
10559
+ return toasts.map((toastObject) => /* @__PURE__ */ jsx(DefaultToastItem, { toast: toastObject }, toastObject.id));
10517
10560
  }
10518
- const TOP_SWIPE_DIRECTION = ["up", "right"];
10519
- function DefaultToastItem({ placement, toast: toastObject }) {
10561
+ function DefaultToastItem({ toast: toastObject }) {
10520
10562
  const { timeout } = useContext(ToastProviderConfigContext);
10521
10563
  const showDescription = Boolean(toastObject.description);
10522
10564
  const showAction = Boolean(toastObject.actionProps?.children);
10523
- const showClose = toastObject.data?.hasClose !== false;
10565
+ const showClose = toastObject.data?.hasClose === true;
10524
10566
  const showIcon = hasLeading(toastObject) || toastObject.data?.hasIcon !== false;
10525
10567
  const progressDuration = toastObject.timeout ?? timeout;
10526
10568
  const showProgress = toastObject.data?.hasProgress === true && toastObject.type !== "loading" && progressDuration > 0;
10527
10569
  return /* @__PURE__ */ jsx(ToastRoot, {
10528
10570
  toast: toastObject,
10529
- swipeDirection: placement.startsWith("top-") ? TOP_SWIPE_DIRECTION : void 0,
10530
10571
  children: /* @__PURE__ */ jsxs(ToastContent, {
10531
10572
  className: toastObject.data?.contentClassName,
10532
10573
  children: [
package/dist/wip.d.mts CHANGED
@@ -698,11 +698,11 @@ type PullToRefreshIndicatorProps = Omit<ComponentProps<"div">, "children"> & {
698
698
  label: string;
699
699
  };
700
700
  /**
701
- * The pull's indicator, with the DS Spinner in it. Render it as the
702
- * scroller's first child: by default it is the space the pull opens, so the
703
- * content moves down with the pull; on Android it travels down over the
704
- * content instead (Material 3). It holds a polite live region that announces
705
- * `label` while refreshing.
701
+ * The pull's indicator. Render it as the scroller's first child: by default
702
+ * it is the space the pull opens, with the DS Spinner in it, so the content
703
+ * moves down with the pull; on Android it is Material 3's own indicator, a
704
+ * disc with a progress arc that travels down over the content. It holds a
705
+ * polite live region that announces `label` while refreshing.
706
706
  */
707
707
  export declare function PullToRefreshIndicator({ pull, label, className, style, ...props }: PullToRefreshIndicatorProps): import("react").JSX.Element;
708
708
  //#endregion
package/dist/wip.mjs CHANGED
@@ -2034,6 +2034,153 @@ function usePullToRefresh(scrollRef, { onRefresh, disabled = false, threshold =
2034
2034
  };
2035
2035
  }
2036
2036
  //#endregion
2037
+ //#region src/components/pull-to-refresh/pull-to-refresh-material-progress.tsx
2038
+ /**
2039
+ * Android's own pull-to-refresh progress, drawn as Material 3 draws it
2040
+ * (Compose's `PullToRefreshDefaults.Indicator`, the one Chrome on Android
2041
+ * shows too): a 2.5px arc of radius 7.5px in a 40px disc. While pulling the
2042
+ * arc grows to 0.8 of the circle and turns a little with the pull, with an
2043
+ * arrowhead at its end, at 0.3 opacity until the pull is armed and fully
2044
+ * opaque from then on. While refreshing it is Material's indeterminate arc,
2045
+ * which grows and shrinks as it turns. Internal: `PullToRefreshIndicator`
2046
+ * renders it on Android.
2047
+ */
2048
+ /** The disc's centre, in its 40px box. */
2049
+ const CENTER = 20;
2050
+ const MATERIAL_ARC_RADIUS = 7.5;
2051
+ const MATERIAL_STROKE_WIDTH = 2.5;
2052
+ const ARROW_WIDTH = 10;
2053
+ const ARROW_HEIGHT = 5;
2054
+ /** The longest the arc gets while pulling, as a share of the circle. */
2055
+ const MATERIAL_MAX_ARC = .8;
2056
+ const CIRCUMFERENCE = 2 * Math.PI * MATERIAL_ARC_RADIUS;
2057
+ /**
2058
+ * The arc for a pull's progress (0 to 1), as Material computes it: nothing
2059
+ * for the first 40%, then the arc grows to `MATERIAL_MAX_ARC` of the circle
2060
+ * and its start turns from -45° to +27° (12 o'clock is -90°), and the
2061
+ * arrowhead grows with it.
2062
+ */
2063
+ function materialArc(progress) {
2064
+ const adjusted = Math.max(Math.min(1, progress) - .4, 0) * 5 / 3;
2065
+ const rotation = (-.25 + .4 * adjusted) * .5;
2066
+ return {
2067
+ startAngle: rotation * 360,
2068
+ endAngle: (rotation + adjusted * MATERIAL_MAX_ARC) * 360,
2069
+ arrowScale: Math.min(1, adjusted)
2070
+ };
2071
+ }
2072
+ const point = (degrees) => {
2073
+ const radians = degrees * Math.PI / 180;
2074
+ return `${CENTER + MATERIAL_ARC_RADIUS * Math.cos(radians)} ${CENTER + MATERIAL_ARC_RADIUS * Math.sin(radians)}`;
2075
+ };
2076
+ /** An SVG path for the arc, clockwise from `startAngle` to `endAngle`. */
2077
+ function arcPath({ startAngle, endAngle }) {
2078
+ const sweep = endAngle - startAngle;
2079
+ if (sweep <= .01) return "";
2080
+ const large = sweep > 180 ? 1 : 0;
2081
+ return `M ${point(startAngle)} A ${MATERIAL_ARC_RADIUS} ${MATERIAL_ARC_RADIUS} 0 ${large} 1 ${point(endAngle)}`;
2082
+ }
2083
+ /**
2084
+ * The arrowhead: a triangle whose base straddles the arc at 3 o'clock and
2085
+ * whose tip points clockwise, turned to the arc's end.
2086
+ */
2087
+ function arrowPoints(scale) {
2088
+ const half = ARROW_WIDTH * scale / 2;
2089
+ const base = 17.5;
2090
+ const x = 27.5;
2091
+ return `${x - half},${base} ${x + half},${base} ${x},${base + ARROW_HEIGHT * scale}`;
2092
+ }
2093
+ const MATERIAL_STYLE = `
2094
+ @keyframes raft-ptr-material-rotate {
2095
+ to {
2096
+ transform: rotate(360deg);
2097
+ }
2098
+ }
2099
+
2100
+ @keyframes raft-ptr-material-dash {
2101
+ 0% {
2102
+ stroke-dasharray: 0.4 ${CIRCUMFERENCE};
2103
+ stroke-dashoffset: 0;
2104
+ }
2105
+ 50% {
2106
+ stroke-dasharray: ${CIRCUMFERENCE * .7} ${CIRCUMFERENCE};
2107
+ stroke-dashoffset: ${-CIRCUMFERENCE * .1};
2108
+ }
2109
+ 100% {
2110
+ stroke-dasharray: ${CIRCUMFERENCE * .7} ${CIRCUMFERENCE};
2111
+ stroke-dashoffset: ${-CIRCUMFERENCE * .99};
2112
+ }
2113
+ }
2114
+
2115
+ [data-slot="pull-to-refresh-material-progress"][data-state="refreshing"] {
2116
+ animation: raft-ptr-material-rotate 1.4s linear infinite;
2117
+ }
2118
+
2119
+ [data-slot="pull-to-refresh-material-progress"][data-state="refreshing"] [data-material-indeterminate] {
2120
+ animation: raft-ptr-material-dash 1.4s ease-in-out infinite;
2121
+ }
2122
+
2123
+ @media (prefers-reduced-motion: reduce) {
2124
+ [data-slot="pull-to-refresh-material-progress"][data-state="refreshing"],
2125
+ [data-slot="pull-to-refresh-material-progress"][data-state="refreshing"] [data-material-indeterminate] {
2126
+ animation: none;
2127
+ }
2128
+
2129
+ [data-slot="pull-to-refresh-material-progress"][data-state="refreshing"] [data-material-indeterminate] {
2130
+ stroke-dasharray: ${CIRCUMFERENCE * .75} ${CIRCUMFERENCE};
2131
+ }
2132
+ }
2133
+ `;
2134
+ const materialStyle = /* @__PURE__ */ jsx("style", {
2135
+ href: "raft-ui-pull-to-refresh-material",
2136
+ precedence: "default",
2137
+ children: MATERIAL_STYLE
2138
+ });
2139
+ const materialProgress = tv({ slots: {
2140
+ root: ["size-10 shrink-0 overflow-visible", "text-info-strong"],
2141
+ determinate: ["transition-opacity duration-150 ease-out motion-reduce:transition-none", "opacity-30 data-armed:opacity-100"],
2142
+ arc: ["fill-none stroke-current [stroke-linecap:butt]"],
2143
+ arrow: ["fill-current"],
2144
+ indeterminate: ["fill-none stroke-current [stroke-linecap:round]"]
2145
+ } });
2146
+ function PullToRefreshMaterialProgress({ state, progress }) {
2147
+ const { root, determinate, arc, arrow, indeterminate } = materialProgress();
2148
+ const refreshing = state === "refreshing";
2149
+ const armed = progress >= 1;
2150
+ const values = materialArc(refreshing ? 1 : progress);
2151
+ return /* @__PURE__ */ jsxs("svg", {
2152
+ "data-slot": "pull-to-refresh-material-progress",
2153
+ "data-state": state,
2154
+ "data-armed": !refreshing && armed ? "" : void 0,
2155
+ viewBox: "0 0 40 40",
2156
+ "aria-hidden": "true",
2157
+ className: root(),
2158
+ children: [materialStyle, refreshing ? /* @__PURE__ */ jsx("circle", {
2159
+ "data-material-indeterminate": "",
2160
+ cx: CENTER,
2161
+ cy: CENTER,
2162
+ r: MATERIAL_ARC_RADIUS,
2163
+ strokeWidth: MATERIAL_STROKE_WIDTH,
2164
+ transform: `rotate(-90 ${CENTER} ${CENTER})`,
2165
+ className: indeterminate()
2166
+ }) : /* @__PURE__ */ jsxs("g", {
2167
+ "data-armed": armed ? "" : void 0,
2168
+ className: determinate(),
2169
+ children: [/* @__PURE__ */ jsx("path", {
2170
+ "data-material-arc": "",
2171
+ d: arcPath(values),
2172
+ strokeWidth: MATERIAL_STROKE_WIDTH,
2173
+ className: arc()
2174
+ }), values.arrowScale > 0 && /* @__PURE__ */ jsx("polygon", {
2175
+ "data-material-arrow": "",
2176
+ points: arrowPoints(values.arrowScale),
2177
+ transform: `rotate(${values.endAngle - 2.5} ${CENTER} ${CENTER})`,
2178
+ className: arrow()
2179
+ })]
2180
+ })]
2181
+ });
2182
+ }
2183
+ //#endregion
2037
2184
  //#region src/components/pull-to-refresh/pull-to-refresh-indicator.tsx
2038
2185
  const pullStepStyle = /* @__PURE__ */ jsx("style", {
2039
2186
  href: "raft-ui-pull-to-refresh",
@@ -2077,47 +2224,93 @@ const pullToRefreshIndicator = tv({
2077
2224
  android: {
2078
2225
  root: ["sticky top-0 z-10 h-0 overflow-visible"],
2079
2226
  surface: [
2080
- "absolute inset-x-0 top-0 mx-auto size-10 rounded-md bg-layer-popover shadow-float ring-1 ring-line-seam",
2227
+ "absolute inset-x-0 top-0 mx-auto size-10 rounded-full bg-layer-popover shadow-float",
2081
2228
  "translate-y-[calc(var(--pull-distance)-100%)]",
2082
- "transition-[translate,opacity]",
2229
+ "transition-[translate,opacity,scale]",
2083
2230
  ...SNAP_BACK,
2084
- "data-[state=idle]:opacity-0"
2231
+ "data-[state=idle]:opacity-0",
2232
+ "data-[exit=finish]:scale-0 data-[exit=finish]:duration-150 data-[exit=finish]:ease-out"
2085
2233
  ]
2086
2234
  }
2087
2235
  } },
2088
2236
  defaultVariants: { platform: "default" }
2089
2237
  });
2090
2238
  /**
2091
- * The pull's indicator, with the DS Spinner in it. Render it as the
2092
- * scroller's first child: by default it is the space the pull opens, so the
2093
- * content moves down with the pull; on Android it travels down over the
2094
- * content instead (Material 3). It holds a polite live region that announces
2095
- * `label` while refreshing.
2239
+ * How the Android disc leaves once idle: `finish` when a refresh has just
2240
+ * ended (it scales down where it held), `cancel` otherwise (it slides back
2241
+ * up). The finish keeps the distance it held, so it doesn't also slide.
2242
+ * `gone` once the scale-down has ended: the disc stays scaled away and its
2243
+ * arc stops turning.
2244
+ */
2245
+ function useAndroidExit(state, distance) {
2246
+ const [previous, setPrevious] = useState(state);
2247
+ const [exit, setExit] = useState({
2248
+ kind: "cancel",
2249
+ distance: 0
2250
+ });
2251
+ const [held, setHeld] = useState(distance);
2252
+ if (state !== previous) {
2253
+ setPrevious(state);
2254
+ if (state === "idle") setExit(previous === "refreshing" ? {
2255
+ kind: "finish",
2256
+ distance: held
2257
+ } : {
2258
+ kind: "cancel",
2259
+ distance: 0
2260
+ });
2261
+ }
2262
+ if (state === "refreshing" && distance !== held) setHeld(distance);
2263
+ const settle = () => {
2264
+ if (exit.kind === "finish") setExit({
2265
+ ...exit,
2266
+ kind: "gone"
2267
+ });
2268
+ };
2269
+ return {
2270
+ ...exit,
2271
+ settle
2272
+ };
2273
+ }
2274
+ /**
2275
+ * The pull's indicator. Render it as the scroller's first child: by default
2276
+ * it is the space the pull opens, with the DS Spinner in it, so the content
2277
+ * moves down with the pull; on Android it is Material 3's own indicator, a
2278
+ * disc with a progress arc that travels down over the content. It holds a
2279
+ * polite live region that announces `label` while refreshing.
2096
2280
  */
2097
2281
  function PullToRefreshIndicator({ pull, label, className, style, ...props }) {
2098
2282
  const { state, distance, progress, platform } = pull;
2099
2283
  const { root, surface, spinner, status } = pullToRefreshIndicator({ platform });
2100
2284
  const android = platform === "android";
2285
+ const exit = useAndroidExit(state, distance);
2286
+ const finishing = android && state === "idle" && exit.kind !== "cancel";
2101
2287
  return /* @__PURE__ */ jsxs("div", {
2102
2288
  "data-slot": "pull-to-refresh-indicator",
2103
2289
  "data-state": state,
2104
2290
  "data-platform": platform,
2105
- "data-pull-step": state === "pulling" ? pullStep(progress) : void 0,
2291
+ "data-pull-step": !android && state === "pulling" ? pullStep(progress) : void 0,
2106
2292
  className: root({ className }),
2107
2293
  style: {
2108
2294
  ...style,
2109
2295
  height: android ? void 0 : distance,
2110
2296
  ["--pull-progress"]: progress,
2111
- ["--pull-distance"]: `${distance}px`
2297
+ ["--pull-distance"]: `${finishing ? exit.distance : distance}px`
2112
2298
  },
2113
2299
  ...props,
2114
2300
  children: [
2115
- pullStepStyle,
2301
+ !android && pullStepStyle,
2116
2302
  /* @__PURE__ */ jsx("div", {
2117
2303
  "data-slot": "pull-to-refresh-surface",
2118
2304
  "data-state": state,
2305
+ "data-exit": finishing ? "finish" : void 0,
2119
2306
  className: surface(),
2120
- children: /* @__PURE__ */ jsx(Spinner, {
2307
+ onTransitionEnd: (event) => {
2308
+ if (event.target === event.currentTarget && event.propertyName === "scale") exit.settle();
2309
+ },
2310
+ children: android ? /* @__PURE__ */ jsx(PullToRefreshMaterialProgress, {
2311
+ state: finishing && exit.kind === "finish" ? "refreshing" : state,
2312
+ progress
2313
+ }) : /* @__PURE__ */ jsx(Spinner, {
2121
2314
  "aria-hidden": "true",
2122
2315
  role: "presentation",
2123
2316
  "aria-label": void 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/raft-ui",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "React components and CSS foundations for the Raft Design System.",
5
5
  "keywords": [
6
6
  "agent-skills",