@ttt-productions/ui-core 0.11.14 → 0.11.16

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 (98) hide show
  1. package/dist/react/components/alert-dialog.js +2 -2
  2. package/dist/react/components/alert-dialog.js.map +1 -1
  3. package/dist/react/components/avatar.js +1 -1
  4. package/dist/react/components/avatar.js.map +1 -1
  5. package/dist/react/components/badge.d.ts +2 -2
  6. package/dist/react/components/button.d.ts +11 -2
  7. package/dist/react/components/button.d.ts.map +1 -1
  8. package/dist/react/components/button.js +20 -7
  9. package/dist/react/components/button.js.map +1 -1
  10. package/dist/react/components/card.js +1 -1
  11. package/dist/react/components/card.js.map +1 -1
  12. package/dist/react/components/chunk-error-recovery.js +2 -2
  13. package/dist/react/components/chunk-error-recovery.js.map +1 -1
  14. package/dist/react/components/consequence-dialog.d.ts +1 -1
  15. package/dist/react/components/consequence-dialog.d.ts.map +1 -1
  16. package/dist/react/components/consequence-dialog.js +2 -3
  17. package/dist/react/components/consequence-dialog.js.map +1 -1
  18. package/dist/react/components/dialog.js +2 -2
  19. package/dist/react/components/dialog.js.map +1 -1
  20. package/dist/react/components/dropdown-menu.d.ts +8 -0
  21. package/dist/react/components/dropdown-menu.d.ts.map +1 -1
  22. package/dist/react/components/dropdown-menu.js +5 -3
  23. package/dist/react/components/dropdown-menu.js.map +1 -1
  24. package/dist/react/components/end-of-list-indicator.js +1 -1
  25. package/dist/react/components/end-of-list-indicator.js.map +1 -1
  26. package/dist/react/components/input.js +1 -1
  27. package/dist/react/components/input.js.map +1 -1
  28. package/dist/react/components/list-pagination.d.ts.map +1 -1
  29. package/dist/react/components/list-pagination.js +19 -1
  30. package/dist/react/components/list-pagination.js.map +1 -1
  31. package/dist/react/components/menubar.js +1 -1
  32. package/dist/react/components/menubar.js.map +1 -1
  33. package/dist/react/components/popover.js +1 -1
  34. package/dist/react/components/popover.js.map +1 -1
  35. package/dist/react/components/scroll-to-top-button.js +1 -1
  36. package/dist/react/components/scroll-to-top-button.js.map +1 -1
  37. package/dist/react/components/search-dropdown.d.ts.map +1 -1
  38. package/dist/react/components/search-dropdown.js +3 -2
  39. package/dist/react/components/search-dropdown.js.map +1 -1
  40. package/dist/react/components/select.d.ts +8 -1
  41. package/dist/react/components/select.d.ts.map +1 -1
  42. package/dist/react/components/select.js +3 -2
  43. package/dist/react/components/select.js.map +1 -1
  44. package/dist/react/components/sheet.js +2 -2
  45. package/dist/react/components/sheet.js.map +1 -1
  46. package/dist/react/components/spinner.d.ts +23 -0
  47. package/dist/react/components/spinner.d.ts.map +1 -0
  48. package/dist/react/components/spinner.js +27 -0
  49. package/dist/react/components/spinner.js.map +1 -0
  50. package/dist/react/components/switch.d.ts +8 -1
  51. package/dist/react/components/switch.d.ts.map +1 -1
  52. package/dist/react/components/switch.js +2 -1
  53. package/dist/react/components/switch.js.map +1 -1
  54. package/dist/react/components/table.js +1 -1
  55. package/dist/react/components/table.js.map +1 -1
  56. package/dist/react/components/tabs.js +1 -1
  57. package/dist/react/components/tabs.js.map +1 -1
  58. package/dist/react/components/textarea.js +1 -1
  59. package/dist/react/components/textarea.js.map +1 -1
  60. package/dist/react/components/toast.js +5 -5
  61. package/dist/react/components/toast.js.map +1 -1
  62. package/dist/react/components/tooltip.js +1 -1
  63. package/dist/react/components/tooltip.js.map +1 -1
  64. package/dist/react/hooks/use-async-action.d.ts +30 -0
  65. package/dist/react/hooks/use-async-action.d.ts.map +1 -0
  66. package/dist/react/hooks/use-async-action.js +39 -0
  67. package/dist/react/hooks/use-async-action.js.map +1 -0
  68. package/dist/react/index.d.ts +4 -0
  69. package/dist/react/index.d.ts.map +1 -1
  70. package/dist/react/index.js +2 -0
  71. package/dist/react/index.js.map +1 -1
  72. package/package.json +8 -4
  73. package/src/react/components/alert-dialog.tsx +2 -2
  74. package/src/react/components/avatar.tsx +1 -1
  75. package/src/react/components/button.tsx +48 -12
  76. package/src/react/components/card.tsx +1 -1
  77. package/src/react/components/chunk-error-recovery.tsx +2 -2
  78. package/src/react/components/consequence-dialog.tsx +4 -6
  79. package/src/react/components/dialog.tsx +2 -2
  80. package/src/react/components/dropdown-menu.tsx +21 -4
  81. package/src/react/components/end-of-list-indicator.tsx +1 -1
  82. package/src/react/components/input.tsx +1 -1
  83. package/src/react/components/list-pagination.tsx +24 -4
  84. package/src/react/components/menubar.tsx +1 -1
  85. package/src/react/components/popover.tsx +1 -1
  86. package/src/react/components/scroll-to-top-button.tsx +1 -1
  87. package/src/react/components/search-dropdown.tsx +5 -4
  88. package/src/react/components/select.tsx +20 -6
  89. package/src/react/components/sheet.tsx +2 -2
  90. package/src/react/components/spinner.tsx +49 -0
  91. package/src/react/components/switch.tsx +16 -4
  92. package/src/react/components/table.tsx +1 -1
  93. package/src/react/components/tabs.tsx +2 -2
  94. package/src/react/components/textarea.tsx +1 -1
  95. package/src/react/components/toast.tsx +5 -5
  96. package/src/react/components/tooltip.tsx +1 -1
  97. package/src/react/hooks/use-async-action.ts +63 -0
  98. package/src/react/index.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttt-productions/ui-core",
3
- "version": "0.11.14",
3
+ "version": "0.11.16",
4
4
  "description": "Shared UI components for TTT Productions projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,7 +49,8 @@
49
49
  "react-dom": ">=19.0.0",
50
50
  "react-hook-form": ">=7.0.0",
51
51
  "@hookform/resolvers": ">=3.0.0",
52
- "@ttt-productions/firebase-helpers": "^0.12.7"
52
+ "@ttt-productions/firebase-helpers": "^0.12.9",
53
+ "lucide-react": "^1.30.0"
53
54
  },
54
55
  "peerDependenciesMeta": {
55
56
  "react": {
@@ -63,6 +64,9 @@
63
64
  },
64
65
  "@hookform/resolvers": {
65
66
  "optional": true
67
+ },
68
+ "lucide-react": {
69
+ "optional": true
66
70
  }
67
71
  },
68
72
  "dependencies": {
@@ -88,7 +92,6 @@
88
92
  "@radix-ui/react-tooltip": "^1.2.8",
89
93
  "class-variance-authority": "^0.7.0",
90
94
  "clsx": "^2.1.1",
91
- "lucide-react": "^0.562.0",
92
95
  "tailwind-merge": "^3.6.0"
93
96
  },
94
97
  "devDependencies": {
@@ -99,7 +102,8 @@
99
102
  "typescript": "^5.8.3",
100
103
  "react-hook-form": "^7.69.0",
101
104
  "@hookform/resolvers": "^5.2.2",
102
- "@ttt-productions/firebase-helpers": "^0.12.7"
105
+ "@ttt-productions/firebase-helpers": "^0.12.9",
106
+ "lucide-react": "^1.30.0"
103
107
  },
104
108
  "author": "DJ (TTT Productions)",
105
109
  "license": "MIT"
@@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
16
16
  >(({ className, ...props }, ref) => (
17
17
  <AlertDialogPrimitive.Overlay
18
18
  className={cn(
19
- "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
19
+ "fixed inset-0 z-50 bg-[color:var(--scrim)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
20
20
  className
21
21
  )}
22
22
  {...props}
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
34
34
  <AlertDialogPrimitive.Content
35
35
  ref={ref}
36
36
  className={cn(
37
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg [&>*]:min-w-0",
37
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 elevation-raised duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg [&>*]:min-w-0",
38
38
  className
39
39
  )}
40
40
  {...props}
@@ -40,7 +40,7 @@ const AvatarFallback = React.forwardRef<
40
40
  <AvatarPrimitive.Fallback
41
41
  ref={ref}
42
42
  className={cn(
43
- "flex h-full w-full items-center justify-center rounded-full bg-white text-primary font-semibold",
43
+ "flex h-full w-full items-center justify-center rounded-full bg-[color:var(--avatar-fallback)] text-primary font-semibold",
44
44
  className
45
45
  )}
46
46
  {...props}
@@ -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",
@@ -14,9 +15,9 @@ const buttonVariants = cva(
14
15
  default:
15
16
  "button-default bg-primary text-primary-foreground hover:bg-primary/90 border-2 border-[hsl(var(--brand-primary-deep))]",
16
17
  destructive:
17
- "bg-destructive text-destructive-foreground hover:bg-destructive/90 border-2 border-[hsl(var(--brand-primary-deep))]",
18
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90 border-2 border-[hsl(var(--destructive-border))]",
18
19
  success:
19
- "bg-green-500 text-primary-foreground hover:bg-green-500/90 border-2 border-[hsl(var(--status-success-border))]",
20
+ "bg-[color:var(--button-success)] text-primary-foreground hover:bg-[color:var(--button-success)]/90 border-2 border-[hsl(var(--status-success-border))]",
20
21
  outline:
21
22
  "border-2 border-border bg-background hover:bg-accent hover:text-accent-foreground text-foreground",
22
23
  secondary:
@@ -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
  )
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
7
7
  ({ className, ...props }, ref) => (
8
8
  <div
9
9
  ref={ref}
10
- className={cn("rounded-lg border-2 card-border bg-card text-card-foreground shadow-xs", className)}
10
+ className={cn("rounded-lg border-2 card-border bg-card text-card-foreground", className)}
11
11
  {...props}
12
12
  />
13
13
  )
@@ -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>
@@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
18
18
  <DialogPrimitive.Overlay
19
19
  ref={ref}
20
20
  className={cn(
21
- "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
21
+ "fixed inset-0 z-50 bg-[color:var(--scrim)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
22
  className
23
23
  )}
24
24
  {...props}
@@ -35,7 +35,7 @@ const DialogContent = React.forwardRef<
35
35
  <DialogPrimitive.Content
36
36
  ref={ref}
37
37
  className={cn(
38
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
38
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 elevation-raised duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
39
39
  "max-h-[90vh] overflow-y-auto [&>*]:min-w-0",
40
40
  className
41
41
  )}
@@ -2,9 +2,11 @@
2
2
 
3
3
  import * as React from "react"
4
4
  import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5
+ import { Slottable } from "@radix-ui/react-slot"
5
6
  import { Check, ChevronRight, Circle } from "lucide-react"
6
7
 
7
8
  import { cn } from "../../lib/utils.js"
9
+ import { Spinner } from "./spinner.js"
8
10
 
9
11
  const DropdownMenu = DropdownMenuPrimitive.Root
10
12
  const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
@@ -41,7 +43,7 @@ const DropdownMenuSubContent = React.forwardRef<
41
43
  <DropdownMenuPrimitive.SubContent
42
44
  ref={ref}
43
45
  className={cn(
44
- "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
46
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground elevation-raised data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
45
47
  className
46
48
  )}
47
49
  {...props}
@@ -58,7 +60,7 @@ const DropdownMenuContent = React.forwardRef<
58
60
  ref={ref}
59
61
  sideOffset={sideOffset}
60
62
  className={cn(
61
- "z-50 min-w-[8rem] overflow-hidden rounded-md border !bg-card p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
63
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border !bg-card p-1 text-popover-foreground elevation-popover data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
62
64
  className
63
65
  )}
64
66
  {...props}
@@ -71,8 +73,16 @@ const DropdownMenuItem = React.forwardRef<
71
73
  React.ElementRef<typeof DropdownMenuPrimitive.Item>,
72
74
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
73
75
  inset?: boolean
76
+ /**
77
+ * The action this item started is in flight: the item disables itself, sets
78
+ * `aria-busy`, and shows the spinner in place of its `icon`. Keeping the menu open
79
+ * meanwhile is the caller's `onSelect` (`event.preventDefault()`, close on settle).
80
+ */
81
+ pending?: boolean
82
+ /** Leading icon. While `pending`, the spinner takes its place. */
83
+ icon?: React.ReactNode
74
84
  }
75
- >(({ className, inset, ...props }, ref) => (
85
+ >(({ className, inset, pending = false, icon, disabled, children, ...props }, ref) => (
76
86
  <DropdownMenuPrimitive.Item
77
87
  ref={ref}
78
88
  className={cn(
@@ -81,7 +91,14 @@ const DropdownMenuItem = React.forwardRef<
81
91
  className
82
92
  )}
83
93
  {...props}
84
- />
94
+ {...(pending ? { "aria-busy": true, "data-pending": "" } : {})}
95
+ disabled={disabled || pending || undefined}
96
+ >
97
+ {/* Direct siblings, never a fragment: under `asChild` Radix Slot finds the Slottable
98
+ among its direct children and renders the icon/spinner inside the child element. */}
99
+ {pending ? <Spinner size="xs" /> : icon}
100
+ <Slottable>{children}</Slottable>
101
+ </DropdownMenuPrimitive.Item>
85
102
  ))
86
103
  DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
87
104
 
@@ -20,7 +20,7 @@ export function EndOfListIndicator({
20
20
  className,
21
21
  )}
22
22
  >
23
- {icon ?? <CheckCircle2 className="h-6 w-6 text-emerald-500" />}
23
+ {icon ?? <CheckCircle2 className="h-6 w-6 text-[color:var(--success-icon)]" />}
24
24
  <p className="text-sm">{message}</p>
25
25
  </div>
26
26
  );
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
9
9
  <input
10
10
  type={type}
11
11
  className={cn(
12
- "flex h-10 w-full rounded-md border-2 border-border bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
12
+ "flex h-10 w-full rounded-md border-2 border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
13
13
  className
14
14
  )}
15
15
  ref={ref}
@@ -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
  );
@@ -98,7 +98,7 @@ const MenubarContent = React.forwardRef<
98
98
  alignOffset={alignOffset}
99
99
  sideOffset={sideOffset}
100
100
  className={cn(
101
- "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
101
+ "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground elevation-popover data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
102
102
  className
103
103
  )}
104
104
  {...props}
@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
19
19
  align={align}
20
20
  sideOffset={sideOffset}
21
21
  className={cn(
22
- "z-50 w-72 rounded-md border !bg-card p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
22
+ "z-50 w-72 rounded-md border !bg-card p-4 text-popover-foreground elevation-popover outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
23
23
  className
24
24
  )}
25
25
  {...props}
@@ -74,7 +74,7 @@ export function ScrollToTopButton({
74
74
  onClick={onClick}
75
75
  aria-label={ariaLabel}
76
76
  className={cn(
77
- "fixed bottom-24 left-1/2 -translate-x-1/2 z-50 rounded-full shadow-lg transition-opacity duration-300",
77
+ "fixed bottom-24 left-1/2 -translate-x-1/2 z-50 rounded-full elevation-raised transition-opacity duration-300",
78
78
  isVisible ? "opacity-100" : "opacity-0 pointer-events-none",
79
79
  className,
80
80
  )}
@@ -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"
@@ -188,14 +189,14 @@ export function SearchDropdown<T>({
188
189
  </div>
189
190
 
190
191
  {showDropdown && (
191
- <div className="absolute z-50 w-full mt-1 bg-popover border rounded-md shadow-lg max-h-60 overflow-auto">
192
+ <div className="absolute z-50 w-full mt-1 bg-popover border rounded-md elevation-raised max-h-60 overflow-auto">
192
193
  {error ? (
193
194
  <div className="px-3 py-2 text-sm text-destructive">
194
195
  {error}
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
@@ -66,7 +80,7 @@ const SelectContent = React.forwardRef<
66
80
  <SelectPrimitive.Content
67
81
  ref={ref}
68
82
  className={cn(
69
- "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
83
+ "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground elevation-popover data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
70
84
  position === "popper" &&
71
85
  "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
72
86
  className
@@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
21
21
  >(({ className, ...props }, ref) => (
22
22
  <SheetPrimitive.Overlay
23
23
  className={cn(
24
- "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
24
+ "fixed inset-0 z-50 bg-[color:var(--scrim)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25
25
  className
26
26
  )}
27
27
  {...props}
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
31
31
  SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32
32
 
33
33
  const sheetVariants = cva(
34
- "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
34
+ "fixed z-50 gap-4 bg-background p-6 elevation-raised transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35
35
  {
36
36
  variants: {
37
37
  side: {
@@ -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 elevation-raised 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
@@ -59,7 +59,7 @@ const TableRow = React.forwardRef<
59
59
  <tr
60
60
  ref={ref}
61
61
  className={cn(
62
- "border-b transition-colors hover:bg-muted/50 dark:hover:bg-white/10 data-[state=selected]:bg-muted",
62
+ "border-b transition-colors hover:bg-[color:var(--table-row-hover)] data-[state=selected]:bg-muted",
63
63
  className
64
64
  )}
65
65
  {...props}