- {icon && (
-
+ {icon != null && (
+
{icon}
)}
-
-
{title}
- {description && (
-
{description}
- )}
-
- {action}
+
+ {(title != null || description != null) && (
+
+ {title != null && (
+
+ {title}
+
+ )}
+
+ {description != null && (
+
+ {description}
+
+ )}
+
+ )}
+
+ {hasActions && (
+
+ {actions?.primary}
+ {actions?.secondary}
+
+ )}
)
}
-export { EmptyState }
-export type { EmptyStateProps }
+export { EmptyState, emptyStateVariants }
diff --git a/packages/ui/src/components/skeleton.tsx b/packages/ui/src/components/skeleton.tsx
index d93ccf3..df09e3b 100644
--- a/packages/ui/src/components/skeleton.tsx
+++ b/packages/ui/src/components/skeleton.tsx
@@ -1,13 +1,151 @@
+import * as React from "react"
import { cn } from "@workspace/ui/lib/utils"
+// ---------------------------------------------------------------------------
+// Base primitive
+//
+// Uses `motion-safe:animate-pulse` so the animation only runs when the user
+// has NOT requested reduced motion. A CSS-level safety net in globals.css
+// provides belt-and-suspenders coverage for older browsers / non-Tailwind
+// class consumers.
+// ---------------------------------------------------------------------------
+
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Presets
+//
+// Each preset is a thin wrapper that fixes the shape, then accepts `className`
+// for width / spacing overrides at the call site.
+// ---------------------------------------------------------------------------
+
+// -- Text line ---------------------------------------------------------------
+// Default: single line, normal text height, rounded-sm matches design tokens.
+export interface SkeletonTextProps extends React.ComponentProps<"div"> {
+ /** Number of lines to render (default 1). */
+ lines?: number
+ /** Height of each line (default "h-3"). */
+ lineHeight?: string
+}
+
+function SkeletonText({
+ lines = 1,
+ lineHeight = "h-3",
+ className,
+ ...props
+}: SkeletonTextProps) {
+ if (lines === 1) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {Array.from({ length: lines }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+// -- Avatar / icon circle ----------------------------------------------------
+// Sizes mirror common usages in the codebase (size-8 default → 32 px).
+export interface SkeletonAvatarProps extends React.ComponentProps<"div"> {
+ /** Tailwind size class (default "size-8"). */
+ size?: string
+}
+
+function SkeletonAvatar({
+ size = "size-8",
+ className,
+ ...props
+}: SkeletonAvatarProps) {
+ return (
+
+ )
+}
+
+// -- Form control / input bar ------------------------------------------------
+// Default: full-width, single input row height.
+function SkeletonControl({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+// -- Card block --------------------------------------------------------------
+// Fills a card-shaped area with an optional aspect ratio via `className`.
+function SkeletonCard({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
)
}
-export { Skeleton }
+// -- Table row ---------------------------------------------------------------
+// Renders a row of N evenly-spaced cells. Pass `cols` to match the real
+// column count; use `className` to set a height other than the default h-9.
+export interface SkeletonTableRowProps extends React.ComponentProps<"div"> {
+ /** Number of cell placeholders (default 4). */
+ cols?: number
+}
+
+function SkeletonTableRow({
+ cols = 4,
+ className,
+ ...props
+}: SkeletonTableRowProps) {
+ return (
+
+ {Array.from({ length: cols }).map((_, i) => (
+
+ ))}
+
+ )
+}
+
+export {
+ Skeleton,
+ SkeletonAvatar,
+ SkeletonCard,
+ SkeletonControl,
+ SkeletonTableRow,
+ SkeletonText,
+}
diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css
index 346bc50..7d03c0c 100644
--- a/packages/ui/src/styles/globals.css
+++ b/packages/ui/src/styles/globals.css
@@ -81,82 +81,99 @@
}
:root {
- --background: oklch(1 0 0);
- --foreground: oklch(0.145 0 0);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.5 0.134 242.749);
- --primary-foreground: oklch(0.977 0.013 236.62);
- --secondary: oklch(0.967 0.001 286.375);
- --secondary-foreground: oklch(0.21 0.006 285.885);
- --muted: oklch(0.97 0 0);
- --muted-foreground: oklch(0.556 0 0);
- --accent: oklch(0.97 0 0);
- --accent-foreground: oklch(0.205 0 0);
- --destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
- --chart-1: oklch(0.809 0.105 251.813);
- --chart-2: oklch(0.623 0.214 259.815);
- --chart-3: oklch(0.546 0.245 262.881);
- --chart-4: oklch(0.488 0.243 264.376);
- --chart-5: oklch(0.424 0.199 265.638);
- --radius: 0;
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.588 0.158 241.966);
- --sidebar-primary-foreground: oklch(0.977 0.013 236.62);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.5 0.134 242.749);
+ --primary-foreground: oklch(0.977 0.013 236.62);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.809 0.105 251.813);
+ --chart-2: oklch(0.623 0.214 259.815);
+ --chart-3: oklch(0.546 0.245 262.881);
+ --chart-4: oklch(0.488 0.243 264.376);
+ --chart-5: oklch(0.424 0.199 265.638);
+ --radius: 0;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.588 0.158 241.966);
+ --sidebar-primary-foreground: oklch(0.977 0.013 236.62);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
}
.dark {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.443 0.11 240.79);
- --primary-foreground: oklch(0.977 0.013 236.62);
- --secondary: oklch(0.274 0.006 286.033);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.269 0 0);
- --accent-foreground: oklch(0.985 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
- --chart-1: oklch(0.809 0.105 251.813);
- --chart-2: oklch(0.623 0.214 259.815);
- --chart-3: oklch(0.546 0.245 262.881);
- --chart-4: oklch(0.488 0.243 264.376);
- --chart-5: oklch(0.424 0.199 265.638);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.685 0.169 237.323);
- --sidebar-primary-foreground: oklch(0.293 0.066 243.157);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.443 0.11 240.79);
+ --primary-foreground: oklch(0.977 0.013 236.62);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.809 0.105 251.813);
+ --chart-2: oklch(0.623 0.214 259.815);
+ --chart-3: oklch(0.546 0.245 262.881);
+ --chart-4: oklch(0.488 0.243 264.376);
+ --chart-5: oklch(0.424 0.199 265.638);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.685 0.169 237.323);
+ --sidebar-primary-foreground: oklch(0.293 0.066 243.157);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
- }
+ }
body {
@apply bg-background text-foreground;
- }
- button:not(:disabled), [role="button"]:not(:disabled) {
+ }
+ button:not(:disabled),
+ [role="button"]:not(:disabled) {
cursor: pointer;
+ }
+}
+
+/* ---------------------------------------------------------------------------
+ * Skeleton – reduced-motion safety net
+ *
+ * `motion-safe:animate-pulse` on the component suppresses the animation at
+ * the Tailwind utility level. This @layer base rule is a belt-and-suspenders
+ * CSS-level fallback for browsers or render paths where the Tailwind variant
+ * is not evaluated (e.g. SSR-only CSS, third-party consumers of the stylesheet).
+ * --------------------------------------------------------------------------- */
+@layer base {
+ @media (prefers-reduced-motion: reduce) {
+ [data-slot="skeleton"] {
+ animation: none;
}
-}
\ No newline at end of file
+ }
+}
diff --git a/packages/ui/vitest.setup.ts b/packages/ui/vitest.setup.ts
new file mode 100644
index 0000000..2d1d1e1
--- /dev/null
+++ b/packages/ui/vitest.setup.ts
@@ -0,0 +1,4 @@
+// The shared `@repo/vitest-config/react` config expects `./vitest.setup.ts`.
+// Import the existing setup module so its side-effects (jest-dom matchers,
+// afterEach cleanup) still run when vitest resolves this path.
+import "./setup-tests"