@ttt-productions/ui-core 0.11.14 → 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/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -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/index.ts +5 -0
|
@@ -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
|
+
}
|
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";
|