@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.
Files changed (53) 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/index.d.ts +4 -0
  39. package/dist/react/index.d.ts.map +1 -1
  40. package/dist/react/index.js +2 -0
  41. package/dist/react/index.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/react/components/button.tsx +46 -10
  44. package/src/react/components/chunk-error-recovery.tsx +2 -2
  45. package/src/react/components/consequence-dialog.tsx +4 -6
  46. package/src/react/components/dropdown-menu.tsx +16 -2
  47. package/src/react/components/list-pagination.tsx +24 -4
  48. package/src/react/components/search-dropdown.tsx +4 -3
  49. package/src/react/components/select.tsx +19 -5
  50. package/src/react/components/spinner.tsx +49 -0
  51. package/src/react/components/switch.tsx +16 -4
  52. package/src/react/hooks/use-async-action.ts +63 -0
  53. 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={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
+ }
@@ -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";