@ttt-productions/ui-core 0.11.12 → 0.11.15

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 (58) hide show
  1. package/dist/react/components/badge.d.ts +2 -2
  2. package/dist/react/components/button.d.ts +11 -2
  3. package/dist/react/components/button.d.ts.map +1 -1
  4. package/dist/react/components/button.js +18 -5
  5. package/dist/react/components/button.js.map +1 -1
  6. package/dist/react/components/chunk-error-recovery.js +2 -2
  7. package/dist/react/components/chunk-error-recovery.js.map +1 -1
  8. package/dist/react/components/consequence-dialog.d.ts +1 -1
  9. package/dist/react/components/consequence-dialog.d.ts.map +1 -1
  10. package/dist/react/components/consequence-dialog.js +2 -3
  11. package/dist/react/components/consequence-dialog.js.map +1 -1
  12. package/dist/react/components/dropdown-menu.d.ts +8 -0
  13. package/dist/react/components/dropdown-menu.d.ts.map +1 -1
  14. package/dist/react/components/dropdown-menu.js +2 -1
  15. package/dist/react/components/dropdown-menu.js.map +1 -1
  16. package/dist/react/components/list-pagination.d.ts.map +1 -1
  17. package/dist/react/components/list-pagination.js +19 -1
  18. package/dist/react/components/list-pagination.js.map +1 -1
  19. package/dist/react/components/search-dropdown.d.ts.map +1 -1
  20. package/dist/react/components/search-dropdown.js +3 -2
  21. package/dist/react/components/search-dropdown.js.map +1 -1
  22. package/dist/react/components/select.d.ts +8 -1
  23. package/dist/react/components/select.d.ts.map +1 -1
  24. package/dist/react/components/select.js +2 -1
  25. package/dist/react/components/select.js.map +1 -1
  26. package/dist/react/components/spinner.d.ts +23 -0
  27. package/dist/react/components/spinner.d.ts.map +1 -0
  28. package/dist/react/components/spinner.js +27 -0
  29. package/dist/react/components/spinner.js.map +1 -0
  30. package/dist/react/components/switch.d.ts +8 -1
  31. package/dist/react/components/switch.d.ts.map +1 -1
  32. package/dist/react/components/switch.js +2 -1
  33. package/dist/react/components/switch.js.map +1 -1
  34. package/dist/react/hooks/use-async-action.d.ts +30 -0
  35. package/dist/react/hooks/use-async-action.d.ts.map +1 -0
  36. package/dist/react/hooks/use-async-action.js +39 -0
  37. package/dist/react/hooks/use-async-action.js.map +1 -0
  38. package/dist/react/hooks/use-return-scroll.d.ts +26 -0
  39. package/dist/react/hooks/use-return-scroll.d.ts.map +1 -0
  40. package/dist/react/hooks/use-return-scroll.js +123 -0
  41. package/dist/react/hooks/use-return-scroll.js.map +1 -0
  42. package/dist/react/index.d.ts +6 -0
  43. package/dist/react/index.d.ts.map +1 -1
  44. package/dist/react/index.js +6 -0
  45. package/dist/react/index.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/react/components/button.tsx +46 -10
  48. package/src/react/components/chunk-error-recovery.tsx +2 -2
  49. package/src/react/components/consequence-dialog.tsx +4 -6
  50. package/src/react/components/dropdown-menu.tsx +16 -2
  51. package/src/react/components/list-pagination.tsx +24 -4
  52. package/src/react/components/search-dropdown.tsx +4 -3
  53. package/src/react/components/select.tsx +19 -5
  54. package/src/react/components/spinner.tsx +49 -0
  55. package/src/react/components/switch.tsx +16 -4
  56. package/src/react/hooks/use-async-action.ts +63 -0
  57. package/src/react/hooks/use-return-scroll.ts +149 -0
  58. package/src/react/index.ts +11 -0
@@ -1,10 +1,11 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { Slot } from "@radix-ui/react-slot"
4
+ import { Slot, Slottable } from "@radix-ui/react-slot"
5
5
  import { cva, type VariantProps } from "class-variance-authority"
6
6
 
7
7
  import { cn } from "../../lib/utils.js"
8
+ import { Spinner } from "./spinner.js"
8
9
 
9
10
  const buttonVariants = cva(
10
11
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-bold ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
@@ -44,19 +45,54 @@ export interface ButtonProps
44
45
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
45
46
  VariantProps<typeof buttonVariants> {
46
47
  asChild?: boolean
48
+ /**
49
+ * Async work this button started is in flight. The button disables itself — so
50
+ * neither a repeat click nor an implicit form resubmit can fire — sets
51
+ * `aria-busy`, and shows the canonical spinner: an icon button (`size="icon"`)
52
+ * swaps its icon for it; any other button shows it in place of its `icon`.
53
+ */
54
+ pending?: boolean
55
+ /** Leading icon, rendered before the label. While `pending`, the spinner takes its place. */
56
+ icon?: React.ReactNode
47
57
  }
48
58
 
49
59
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
50
- ({ className, variant, size, asChild = false, children, ...props }, ref) => {
51
- const Comp = asChild ? Slot : "button"
60
+ ({ className, variant, size, asChild = false, pending = false, icon, disabled, children, ...props }, ref) => {
61
+ const shared = {
62
+ ...props,
63
+ ...(pending ? { "aria-busy": true, "data-pending": "" } : {}),
64
+ disabled: disabled || pending || undefined,
65
+ className: cn(buttonVariants({ variant, size, className })),
66
+ }
67
+ const spinner = <Spinner size={size === "lg" ? "sm" : "xs"} />
68
+ const leading = pending ? spinner : icon
69
+
70
+ if (asChild) {
71
+ // Slot finds the Slottable among its DIRECT children, so the leading slot and the
72
+ // Slottable must be siblings here — never wrapped in a fragment.
73
+ return leading ? (
74
+ <Slot ref={ref} {...shared}>
75
+ {leading}
76
+ <Slottable>{children}</Slottable>
77
+ </Slot>
78
+ ) : (
79
+ <Slot ref={ref} {...shared}>
80
+ {children}
81
+ </Slot>
82
+ )
83
+ }
84
+
52
85
  return (
53
- <Comp
54
- className={cn(buttonVariants({ variant, size, className }))}
55
- ref={ref}
56
- {...props}
57
- >
58
- {children}
59
- </Comp>
86
+ <button ref={ref} {...shared}>
87
+ {size === "icon" ? (
88
+ pending ? spinner : children
89
+ ) : (
90
+ <>
91
+ {leading}
92
+ {children}
93
+ </>
94
+ )}
95
+ </button>
60
96
  )
61
97
  }
62
98
  )
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { Loader2 } from "lucide-react";
4
+ import { Spinner } from "./spinner.js";
5
5
 
6
6
  export interface ChunkErrorRecoveryProps {
7
7
  children: React.ReactNode;
@@ -48,7 +48,7 @@ export function ChunkErrorRecovery({
48
48
  if (hasError) {
49
49
  return (
50
50
  <div className="flex flex-col h-screen w-screen items-center justify-center gap-4 bg-background">
51
- <Loader2 className="h-10 w-10 animate-spin" />
51
+ <Spinner size="xl" />
52
52
  <p className="text-muted-foreground">{loadingMessage}</p>
53
53
  </div>
54
54
  );
@@ -14,7 +14,7 @@ import { Button } from './button.js';
14
14
  import { Textarea } from './textarea.js';
15
15
  import { Input } from './input.js';
16
16
  import { Label } from './label.js';
17
- import { Loader2, Zap, Clock, RotateCcw } from 'lucide-react';
17
+ import { Zap, Clock, RotateCcw } from 'lucide-react';
18
18
 
19
19
  /**
20
20
  * The inline required-reason config. The caller OWNS the reason value/onChange (draft state lives at
@@ -71,7 +71,7 @@ export interface ConsequenceDialogProps {
71
71
 
72
72
  /**
73
73
  * Runs on confirm. May be async — while it is pending the dialog STAYS OPEN, both buttons disable,
74
- * and a `Loader2` spins in the confirm button. Closes on resolve. If it rejects the dialog
74
+ * and the confirm button shows its pending spinner. Closes on resolve. If it rejects the dialog
75
75
  * stays open so the caller's own surface (error toast, TOTP step-up) can show and the operator can
76
76
  * retry or cancel. This component takes no Sentry/monitoring dependency — every error path
77
77
  * belongs to the caller's `onConfirm`.
@@ -184,7 +184,6 @@ export function ConsequenceDialog({
184
184
  (reason.maxLength === undefined || reason.value.length <= reason.maxLength);
185
185
 
186
186
  const typedOk = !typedConfirmation ? true : typedValue === typedConfirmation.phrase;
187
- const confirmDisabled = pending || !reasonOk || !typedOk;
188
187
 
189
188
  return (
190
189
  <AlertDialog open={actualOpen} onOpenChange={handleOpenChange}>
@@ -263,10 +262,9 @@ export function ConsequenceDialog({
263
262
  <Button
264
263
  variant={destructive ? 'destructive' : 'default'}
265
264
  onClick={handleConfirm}
266
- disabled={confirmDisabled}
267
- aria-busy={pending}
265
+ disabled={!reasonOk || !typedOk}
266
+ pending={pending}
268
267
  >
269
- {pending ? <Loader2 className="mr-2 spinner-xs" aria-hidden="true" /> : null}
270
268
  {confirmLabel}
271
269
  </Button>
272
270
  </AlertDialogFooter>
@@ -5,6 +5,7 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5
5
  import { Check, ChevronRight, Circle } from "lucide-react"
6
6
 
7
7
  import { cn } from "../../lib/utils.js"
8
+ import { Spinner } from "./spinner.js"
8
9
 
9
10
  const DropdownMenu = DropdownMenuPrimitive.Root
10
11
  const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
@@ -71,8 +72,16 @@ const DropdownMenuItem = React.forwardRef<
71
72
  React.ElementRef<typeof DropdownMenuPrimitive.Item>,
72
73
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
73
74
  inset?: boolean
75
+ /**
76
+ * The action this item started is in flight: the item disables itself, sets
77
+ * `aria-busy`, and shows the spinner in place of its `icon`. Keeping the menu open
78
+ * meanwhile is the caller's `onSelect` (`event.preventDefault()`, close on settle).
79
+ */
80
+ pending?: boolean
81
+ /** Leading icon. While `pending`, the spinner takes its place. */
82
+ icon?: React.ReactNode
74
83
  }
75
- >(({ className, inset, ...props }, ref) => (
84
+ >(({ className, inset, pending = false, icon, disabled, children, ...props }, ref) => (
76
85
  <DropdownMenuPrimitive.Item
77
86
  ref={ref}
78
87
  className={cn(
@@ -81,7 +90,12 @@ const DropdownMenuItem = React.forwardRef<
81
90
  className
82
91
  )}
83
92
  {...props}
84
- />
93
+ {...(pending ? { "aria-busy": true, "data-pending": "" } : {})}
94
+ disabled={disabled || pending || undefined}
95
+ >
96
+ {pending ? <Spinner size="xs" /> : icon}
97
+ {children}
98
+ </DropdownMenuPrimitive.Item>
85
99
  ))
86
100
  DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
87
101
 
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useState } from "react";
3
4
  import { ChevronLeft, ChevronRight } from "lucide-react";
4
5
  import { Button } from "./button.js";
5
6
  import { cn } from "../../lib/utils.js";
@@ -43,6 +44,17 @@ export function ListPagination({ pagination, busy = false, className }: ListPagi
43
44
  const { currentPage, totalPages, canPreviousPage, canNextPage, goToPreviousPage, goToNextPage } =
44
45
  pagination;
45
46
 
47
+ // The control that started the in-flight page shows the spinner. Cleared when `busy`
48
+ // ends — a render-phase adjustment, so there is no effect and no stale frame.
49
+ const [clicked, setClicked] = useState<"previous" | "next" | null>(null);
50
+ const [wasBusy, setWasBusy] = useState(busy);
51
+ if (busy !== wasBusy) {
52
+ setWasBusy(busy);
53
+ if (!busy) setClicked(null);
54
+ }
55
+ const previousPending = busy && clicked === "previous";
56
+ const nextPending = busy && clicked === "next";
57
+
46
58
  // One visibility rule for both flavors. For a known total this is exactly
47
59
  // `totalPages > 1`: page state is clamped into `1..totalPages`, so a
48
60
  // single-page list has no reachable neighbour in either direction, and a
@@ -56,11 +68,15 @@ export function ListPagination({ pagination, busy = false, className }: ListPagi
56
68
  <Button
57
69
  variant="outline"
58
70
  size="sm"
59
- onClick={goToPreviousPage}
71
+ onClick={() => {
72
+ setClicked("previous");
73
+ goToPreviousPage();
74
+ }}
60
75
  disabled={busy || !canPreviousPage}
76
+ pending={previousPending}
77
+ icon={<ChevronLeft className="icon-xs" />}
61
78
  className="h-11 min-w-11"
62
79
  >
63
- <ChevronLeft className="icon-xs mr-1" />
64
80
  Previous
65
81
  </Button>
66
82
  <span className="text-counter" role="status">
@@ -69,12 +85,16 @@ export function ListPagination({ pagination, busy = false, className }: ListPagi
69
85
  <Button
70
86
  variant="outline"
71
87
  size="sm"
72
- onClick={goToNextPage}
88
+ onClick={() => {
89
+ setClicked("next");
90
+ goToNextPage();
91
+ }}
73
92
  disabled={busy || !canNextPage}
93
+ pending={nextPending}
74
94
  className="h-11 min-w-11"
75
95
  >
76
96
  Next
77
- <ChevronRight className="icon-xs ml-1" />
97
+ {nextPending ? null : <ChevronRight className="icon-xs" />}
78
98
  </Button>
79
99
  </div>
80
100
  );
@@ -3,7 +3,8 @@
3
3
  import { useState, useRef, useEffect } from 'react';
4
4
  import { Input } from './input.js';
5
5
  import { Label } from './label.js';
6
- import { Loader2, X, Search } from 'lucide-react';
6
+ import { X, Search } from 'lucide-react';
7
+ import { Spinner } from './spinner.js';
7
8
  import { cn } from "../../lib/utils.js";
8
9
 
9
10
  export interface SearchDropdownProps<T> {
@@ -173,7 +174,7 @@ export function SearchDropdown<T>({
173
174
 
174
175
  <div className="absolute right-3 top-1/2 -translate-y-1/2">
175
176
  {isLoading ? (
176
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
177
+ <Spinner size="xs" className="text-muted-foreground" />
177
178
  ) : value ? (
178
179
  <button
179
180
  type="button"
@@ -195,7 +196,7 @@ export function SearchDropdown<T>({
195
196
  </div>
196
197
  ) : isLoading ? (
197
198
  <div className="px-3 py-2 text-sm text-muted-foreground flex items-center gap-2">
198
- <Loader2 className="h-4 w-4 animate-spin" />
199
+ <Spinner size="xs" />
199
200
  Searching...
200
201
  </div>
201
202
  ) : results.length === 0 ? (
@@ -5,6 +5,7 @@ import * as SelectPrimitive from "@radix-ui/react-select"
5
5
  import { Check, ChevronDown, ChevronUp } from "lucide-react"
6
6
 
7
7
  import { cn } from "../../lib/utils.js"
8
+ import { Spinner } from "./spinner.js"
8
9
 
9
10
  const Select = SelectPrimitive.Root
10
11
  const SelectGroup = SelectPrimitive.Group
@@ -12,8 +13,15 @@ const SelectValue = SelectPrimitive.Value
12
13
 
13
14
  const SelectTrigger = React.forwardRef<
14
15
  React.ElementRef<typeof SelectPrimitive.Trigger>,
15
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
16
- >(({ className, children, ...props }, ref) => (
16
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
17
+ /**
18
+ * The change this select started is in flight: the trigger disables itself, sets
19
+ * `aria-busy`, and shows the spinner in place of its chevron. It keeps showing the
20
+ * COMMITTED value — the caller changes `value` only once the write lands.
21
+ */
22
+ pending?: boolean
23
+ }
24
+ >(({ className, children, pending = false, disabled, ...props }, ref) => (
17
25
  <SelectPrimitive.Trigger
18
26
  ref={ref}
19
27
  className={cn(
@@ -21,11 +29,17 @@ const SelectTrigger = React.forwardRef<
21
29
  className
22
30
  )}
23
31
  {...props}
32
+ {...(pending ? { "aria-busy": true, "data-pending": "" } : {})}
33
+ disabled={disabled || pending || undefined}
24
34
  >
25
35
  {children}
26
- <SelectPrimitive.Icon asChild>
27
- <ChevronDown className="h-4 w-4 opacity-50" />
28
- </SelectPrimitive.Icon>
36
+ {pending ? (
37
+ <Spinner size="xs" />
38
+ ) : (
39
+ <SelectPrimitive.Icon asChild>
40
+ <ChevronDown className="h-4 w-4 opacity-50" />
41
+ </SelectPrimitive.Icon>
42
+ )}
29
43
  </SelectPrimitive.Trigger>
30
44
  ))
31
45
  SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
@@ -0,0 +1,49 @@
1
+ "use client"
2
+
3
+ import { Loader2 } from "lucide-react"
4
+
5
+ import { cn } from "../../lib/utils.js"
6
+
7
+ export type SpinnerSize = "xs" | "sm" | "md" | "lg" | "xl"
8
+
9
+ export interface SpinnerProps {
10
+ /** Maps onto the theme-core `spinner-*` classes. Defaults to `xs` (the in-control size). */
11
+ size?: SpinnerSize
12
+ /**
13
+ * Screen-reader text. When set, the spinner becomes a `status` live region that
14
+ * announces it. Omit it for a spinner inside a control that already carries its
15
+ * own busy state (`aria-busy`) — the control speaks for it.
16
+ */
17
+ label?: string
18
+ className?: string
19
+ }
20
+
21
+ const SIZE_CLASS: Record<SpinnerSize, string> = {
22
+ xs: "spinner-xs",
23
+ sm: "spinner-sm",
24
+ md: "spinner-md",
25
+ lg: "spinner-lg",
26
+ xl: "spinner-xl",
27
+ }
28
+
29
+ /**
30
+ * The ONE loading spinner. Every in-progress indicator renders through this
31
+ * component (or through a control's `pending` prop, which renders it), so the
32
+ * spinner's look, size scale, and color rule have one change point. The
33
+ * theme-core `spinner-*` classes own size, animation, and color — including the
34
+ * `button .spinner-*` rule that makes an in-button spinner inherit the button's
35
+ * text color so it is never primary-on-primary.
36
+ */
37
+ function Spinner({ size = "xs", label, className }: SpinnerProps) {
38
+ if (!label) {
39
+ return <Loader2 className={cn(SIZE_CLASS[size], className)} aria-hidden="true" />
40
+ }
41
+ return (
42
+ <span role="status" className={cn("inline-flex items-center justify-center", className)}>
43
+ <Loader2 className={SIZE_CLASS[size]} aria-hidden="true" />
44
+ <span className="sr-only">{label}</span>
45
+ </span>
46
+ )
47
+ }
48
+
49
+ export { Spinner }
@@ -4,24 +4,36 @@ import * as React from "react"
4
4
  import * as SwitchPrimitives from "@radix-ui/react-switch"
5
5
 
6
6
  import { cn } from "../../lib/utils.js"
7
+ import { Spinner } from "./spinner.js"
7
8
 
8
9
  const Switch = React.forwardRef<
9
10
  React.ElementRef<typeof SwitchPrimitives.Root>,
10
- React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
11
- >(({ className, ...props }, ref) => (
11
+ React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> & {
12
+ /**
13
+ * The change this switch started is in flight: the switch disables itself, sets
14
+ * `aria-busy`, and spins inside its thumb. It keeps showing the COMMITTED state —
15
+ * the caller flips `checked` only once the write lands.
16
+ */
17
+ pending?: boolean
18
+ }
19
+ >(({ className, pending = false, disabled, ...props }, ref) => (
12
20
  <SwitchPrimitives.Root
13
21
  className={cn(
14
22
  "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
15
23
  className
16
24
  )}
17
25
  {...props}
26
+ {...(pending ? { "aria-busy": true, "data-pending": "" } : {})}
27
+ disabled={disabled || pending || undefined}
18
28
  ref={ref}
19
29
  >
20
30
  <SwitchPrimitives.Thumb
21
31
  className={cn(
22
- "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
32
+ "pointer-events-none flex h-5 w-5 items-center justify-center rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
23
33
  )}
24
- />
34
+ >
35
+ {pending ? <Spinner size="xs" /> : null}
36
+ </SwitchPrimitives.Thumb>
25
37
  </SwitchPrimitives.Root>
26
38
  ))
27
39
  Switch.displayName = SwitchPrimitives.Root.displayName
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import { useCallback, useRef, useState } from "react";
4
+
5
+ export interface UseAsyncActionOptions {
6
+ /**
7
+ * Receives anything the action throws. REQUIRED, so every call site decides what a
8
+ * failure means (surface it, capture it, or accept that another layer already did):
9
+ * `run` itself never rejects, so a fire-and-forget `onClick={() => void run()}` can
10
+ * never become an unhandled rejection.
11
+ */
12
+ onError: (error: unknown) => void;
13
+ }
14
+
15
+ export interface AsyncAction<TArgs extends unknown[]> {
16
+ /**
17
+ * Starts the action unless one is already in flight — a repeat call while pending is
18
+ * ignored. Resolves once the action settles: `true` when it completed, `false` when it
19
+ * was ignored or threw. Never rejects.
20
+ */
21
+ run: (...args: TArgs) => Promise<boolean>;
22
+ /** True from the moment `run` starts the action until it settles. */
23
+ pending: boolean;
24
+ }
25
+
26
+ /**
27
+ * Pending state + repeat guard for async work that is NOT a React Query mutation
28
+ * (a local media step, a sign-out, an awaited navigation). Feed `pending` to the
29
+ * control's own `pending` prop. Server writes stay mutations — this hook does not
30
+ * replace the data layer's one mutation path.
31
+ *
32
+ * The guard is a ref, not the state, so two clicks inside one frame cannot both start
33
+ * the action.
34
+ */
35
+ export function useAsyncAction<TArgs extends unknown[]>(
36
+ action: (...args: TArgs) => Promise<unknown>,
37
+ options: UseAsyncActionOptions,
38
+ ): AsyncAction<TArgs> {
39
+ const [pending, setPending] = useState(false);
40
+ const inFlightRef = useRef(false);
41
+ const actionRef = useRef(action);
42
+ const onErrorRef = useRef(options.onError);
43
+ actionRef.current = action;
44
+ onErrorRef.current = options.onError;
45
+
46
+ const run = useCallback(async (...args: TArgs): Promise<boolean> => {
47
+ if (inFlightRef.current) return false;
48
+ inFlightRef.current = true;
49
+ setPending(true);
50
+ try {
51
+ await actionRef.current(...args);
52
+ return true;
53
+ } catch (error) {
54
+ onErrorRef.current(error);
55
+ return false;
56
+ } finally {
57
+ inFlightRef.current = false;
58
+ setPending(false);
59
+ }
60
+ }, []);
61
+
62
+ return { run, pending };
63
+ }
@@ -0,0 +1,149 @@
1
+ "use client";
2
+
3
+ // Return-scroll: bring a list back to where the user left it after a REAL route change.
4
+ //
5
+ // A same-page view swap keeps its component mounted and can restore from a ref; a list
6
+ // whose detail pages have their own URLs unmounts, so the offset has to survive in
7
+ // per-tab storage and the restore has to wait until the list is displayed at its real
8
+ // height again. This hook is the ONE owner of that mechanism. It knows nothing about
9
+ // what the list is — the caller supplies a canonical key, decides eligibility once at
10
+ // mount, and reports readiness.
11
+ //
12
+ // Contract (every rule below is tested):
13
+ // - `save()` writes { y, savedAt } for `key` — call it immediately before navigating
14
+ // away (inside any guarded-navigation callback, so a cancelled leave writes nothing).
15
+ // - `eligible` is latched per key the first time the hook sees that key. Not eligible
16
+ // ⇒ the entry is deleted immediately and permanently for that key; a later flip
17
+ // of `eligible` or `ready` never re-arms the restore.
18
+ // - `ready` ⇒ the restore fires ONCE, in a requestAnimationFrame, `behavior: "instant"`,
19
+ // then consumes the entry. The scheduled frame is cancelled on unmount or key change.
20
+ // React Strict Mode's mount/unmount/mount cancels and reschedules, so exactly one
21
+ // scroll happens and the entry is consumed exactly once.
22
+ // - Blocked, unavailable, or malformed storage is a silent no-op.
23
+
24
+ import * as React from "react";
25
+
26
+ const STORAGE_PREFIX = "return-scroll:";
27
+
28
+ export interface ReturnScrollEntry {
29
+ /** The saved `window.scrollY`. */
30
+ y: number;
31
+ /** Epoch ms at save time (diagnostic; the caller decides eligibility, not the hook). */
32
+ savedAt: number;
33
+ }
34
+
35
+ export interface UseReturnScrollOptions {
36
+ /** The caller's CANONICAL list URL — equivalent list states must give the same key. */
37
+ key: string;
38
+ /** Decided by the caller ONCE at mount (e.g. "the list's data was already cached on first render"). */
39
+ eligible: boolean;
40
+ /** The list is displayed at its real height: data present, no error surface in its place. */
41
+ ready: boolean;
42
+ }
43
+
44
+ export interface UseReturnScrollResult {
45
+ /** Record the current window offset for `key`. Call right before navigating away. */
46
+ save: () => void;
47
+ }
48
+
49
+ function storageKey(key: string): string {
50
+ return `${STORAGE_PREFIX}${key}`;
51
+ }
52
+
53
+ function sessionStorageOrNull(): Storage | null {
54
+ try {
55
+ if (typeof window === "undefined") return null;
56
+ return window.sessionStorage ?? null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /** The stored entry for `key`, or null when absent, unreadable, or malformed. */
63
+ export function readReturnScroll(key: string): ReturnScrollEntry | null {
64
+ const storage = sessionStorageOrNull();
65
+ if (!storage) return null;
66
+ try {
67
+ const raw = storage.getItem(storageKey(key));
68
+ if (!raw) return null;
69
+ const parsed: unknown = JSON.parse(raw);
70
+ if (typeof parsed !== "object" || parsed === null) return null;
71
+ const { y, savedAt } = parsed as { y?: unknown; savedAt?: unknown };
72
+ if (typeof y !== "number" || !Number.isFinite(y) || y < 0) return null;
73
+ return { y, savedAt: typeof savedAt === "number" ? savedAt : 0 };
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** Record `y` (default: the current `window.scrollY`) for `key`. No-op without storage. */
80
+ export function saveReturnScroll(key: string, y?: number): void {
81
+ const storage = sessionStorageOrNull();
82
+ if (!storage) return;
83
+ try {
84
+ const offset = typeof y === "number" ? y : window.scrollY;
85
+ const entry: ReturnScrollEntry = { y: offset, savedAt: Date.now() };
86
+ storage.setItem(storageKey(key), JSON.stringify(entry));
87
+ } catch {
88
+ /* storage blocked or full — nothing to restore later, which is the honest outcome */
89
+ }
90
+ }
91
+
92
+ /** Drop the entry for `key` (e.g. the list changed underneath the saved offset). */
93
+ export function clearReturnScroll(key: string): void {
94
+ const storage = sessionStorageOrNull();
95
+ if (!storage) return;
96
+ try {
97
+ storage.removeItem(storageKey(key));
98
+ } catch {
99
+ /* nothing to clear */
100
+ }
101
+ }
102
+
103
+ interface Decision {
104
+ key: string;
105
+ /** The offset to restore, or null when this key's return is not restorable. */
106
+ y: number | null;
107
+ done: boolean;
108
+ }
109
+
110
+ export function useReturnScroll({ key, eligible, ready }: UseReturnScrollOptions): UseReturnScrollResult {
111
+ const decisionRef = React.useRef<Decision | null>(null);
112
+
113
+ // Decide ONCE per key, the first time the hook sees it. Effects may read and write refs;
114
+ // the decision is latched, so a later `eligible` flip finds it already made and returns.
115
+ React.useEffect(() => {
116
+ if (decisionRef.current?.key === key) return;
117
+ const entry = readReturnScroll(key);
118
+ if (!entry) {
119
+ decisionRef.current = { key, y: null, done: true };
120
+ return;
121
+ }
122
+ if (!eligible) {
123
+ // A cold return: nothing cached to restore against. Discard permanently for this visit.
124
+ clearReturnScroll(key);
125
+ decisionRef.current = { key, y: null, done: true };
126
+ return;
127
+ }
128
+ decisionRef.current = { key, y: entry.y, done: false };
129
+ }, [key, eligible]);
130
+
131
+ // Restore once the list is displayed at its real height. The frame is cancelled on unmount
132
+ // or key change; Strict Mode's re-run reschedules it, so exactly one scroll happens.
133
+ React.useEffect(() => {
134
+ const decision = decisionRef.current;
135
+ if (!decision || decision.key !== key || decision.y === null || decision.done || !ready) return;
136
+ if (typeof window === "undefined") return;
137
+ const y = decision.y;
138
+ const frame = window.requestAnimationFrame(() => {
139
+ window.scrollTo({ top: y, left: 0, behavior: "instant" as ScrollBehavior });
140
+ clearReturnScroll(key);
141
+ decision.done = true;
142
+ });
143
+ return () => window.cancelAnimationFrame(frame);
144
+ }, [key, ready]);
145
+
146
+ const save = React.useCallback(() => saveReturnScroll(key), [key]);
147
+
148
+ return { save };
149
+ }
@@ -2,6 +2,8 @@
2
2
 
3
3
  export type { ButtonProps } from "./components/button.js";
4
4
  export { Button, buttonVariants } from "./components/button.js";
5
+ export type { SpinnerProps, SpinnerSize } from "./components/spinner.js";
6
+ export { Spinner } from "./components/spinner.js";
5
7
  export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from "./components/card.js";
6
8
  export { Label } from "./components/label.js";
7
9
  export { Textarea } from "./components/textarea.js";
@@ -49,6 +51,9 @@ export { ScreenAdaptiveView } from "./components/layout/screen-adaptive-view.js"
49
51
 
50
52
  export { useMediaQuery } from "./hooks/use-media-query.js";
51
53
 
54
+ export { useAsyncAction } from "./hooks/use-async-action.js";
55
+ export type { AsyncAction, UseAsyncActionOptions } from "./hooks/use-async-action.js";
56
+
52
57
  // Shared building blocks
53
58
  export { RelativeTime } from "./components/relative-time.js";
54
59
  export type { RelativeTimeProps } from "./components/relative-time.js";
@@ -72,3 +77,9 @@ export type { ListPaginationState, PagedList, UsePagedListOptions } from "./hook
72
77
 
73
78
  export { useCursorPage } from "./hooks/use-cursor-page.js";
74
79
  export type { CursorPage, CursorPager, UseCursorPageOptions } from "./hooks/use-cursor-page.js";
80
+
81
+ // Return-scroll: restore a list's window offset when the user comes back to it after a
82
+ // route change (save on leave, restore once on an eligible + ready return, keyed by the
83
+ // caller's canonical list URL).
84
+ export { useReturnScroll, saveReturnScroll, clearReturnScroll, readReturnScroll } from "./hooks/use-return-scroll.js";
85
+ export type { ReturnScrollEntry, UseReturnScrollOptions, UseReturnScrollResult } from "./hooks/use-return-scroll.js";