@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.
- package/dist/react/components/badge.d.ts +2 -2
- package/dist/react/components/button.d.ts +11 -2
- package/dist/react/components/button.d.ts.map +1 -1
- package/dist/react/components/button.js +18 -5
- package/dist/react/components/button.js.map +1 -1
- package/dist/react/components/chunk-error-recovery.js +2 -2
- package/dist/react/components/chunk-error-recovery.js.map +1 -1
- package/dist/react/components/consequence-dialog.d.ts +1 -1
- package/dist/react/components/consequence-dialog.d.ts.map +1 -1
- package/dist/react/components/consequence-dialog.js +2 -3
- package/dist/react/components/consequence-dialog.js.map +1 -1
- package/dist/react/components/dropdown-menu.d.ts +8 -0
- package/dist/react/components/dropdown-menu.d.ts.map +1 -1
- package/dist/react/components/dropdown-menu.js +2 -1
- package/dist/react/components/dropdown-menu.js.map +1 -1
- package/dist/react/components/list-pagination.d.ts.map +1 -1
- package/dist/react/components/list-pagination.js +19 -1
- package/dist/react/components/list-pagination.js.map +1 -1
- package/dist/react/components/search-dropdown.d.ts.map +1 -1
- package/dist/react/components/search-dropdown.js +3 -2
- package/dist/react/components/search-dropdown.js.map +1 -1
- package/dist/react/components/select.d.ts +8 -1
- package/dist/react/components/select.d.ts.map +1 -1
- package/dist/react/components/select.js +2 -1
- package/dist/react/components/select.js.map +1 -1
- package/dist/react/components/spinner.d.ts +23 -0
- package/dist/react/components/spinner.d.ts.map +1 -0
- package/dist/react/components/spinner.js +27 -0
- package/dist/react/components/spinner.js.map +1 -0
- package/dist/react/components/switch.d.ts +8 -1
- package/dist/react/components/switch.d.ts.map +1 -1
- package/dist/react/components/switch.js +2 -1
- package/dist/react/components/switch.js.map +1 -1
- package/dist/react/hooks/use-async-action.d.ts +30 -0
- package/dist/react/hooks/use-async-action.d.ts.map +1 -0
- package/dist/react/hooks/use-async-action.js +39 -0
- package/dist/react/hooks/use-async-action.js.map +1 -0
- package/dist/react/hooks/use-return-scroll.d.ts +26 -0
- package/dist/react/hooks/use-return-scroll.d.ts.map +1 -0
- package/dist/react/hooks/use-return-scroll.js +123 -0
- package/dist/react/hooks/use-return-scroll.js.map +1 -0
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +6 -0
- package/dist/react/index.js.map +1 -1
- package/package.json +3 -3
- package/src/react/components/button.tsx +46 -10
- package/src/react/components/chunk-error-recovery.tsx +2 -2
- package/src/react/components/consequence-dialog.tsx +4 -6
- package/src/react/components/dropdown-menu.tsx +16 -2
- package/src/react/components/list-pagination.tsx +24 -4
- package/src/react/components/search-dropdown.tsx +4 -3
- package/src/react/components/select.tsx +19 -5
- package/src/react/components/spinner.tsx +49 -0
- package/src/react/components/switch.tsx +16 -4
- package/src/react/hooks/use-async-action.ts +63 -0
- package/src/react/hooks/use-return-scroll.ts +149 -0
- 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
|
|
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
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 {
|
|
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
|
-
<
|
|
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 {
|
|
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
|
|
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={
|
|
267
|
-
|
|
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={
|
|
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={
|
|
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
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -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";
|