@vvedantb/ui 0.4.3 → 0.5.0

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.
@@ -0,0 +1,130 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import { Slot, Slottable } from "@radix-ui/react-slot";
3
+ import { Button } from "../../src/ui/button";
4
+ import { cn } from "../../src/utils/cn";
5
+ import { railTileClass, railTileStateClass } from "./app-shell-classes";
6
+ import { SidebarIconTooltip } from "./SidebarIconTooltip";
7
+
8
+ export type SidebarRailProps = Omit<ComponentPropsWithoutRef<"div">, "children" | "className"> & {
9
+ /** Pinned at the top, above the scroll body. */
10
+ top?: ReactNode;
11
+ /** Scroll body. */
12
+ children?: ReactNode;
13
+ /** Pinned at the bottom. */
14
+ footer?: ReactNode;
15
+ /** Adds a right border between the rail and the panel. Default `false`. */
16
+ showDivider?: boolean;
17
+ /** Render the body as its single child element (for example a tabs list), with the body classes merged. */
18
+ bodyAsChild?: boolean;
19
+ className?: string;
20
+ topClassName?: string;
21
+ bodyClassName?: string;
22
+ footerClassName?: string;
23
+ };
24
+
25
+ /** Icon rail column. Width reads `--app-shell-rail-width`; override with `className`. Other props go to the root `<div>`. */
26
+ export function SidebarRail({
27
+ top,
28
+ children,
29
+ footer,
30
+ showDivider = false,
31
+ bodyAsChild = false,
32
+ className,
33
+ topClassName,
34
+ bodyClassName,
35
+ footerClassName,
36
+ ...props
37
+ }: SidebarRailProps) {
38
+ const Body = bodyAsChild ? Slot : "div";
39
+ return (
40
+ <div
41
+ {...props}
42
+ className={cn(
43
+ "flex h-full w-[var(--app-shell-rail-width)] shrink-0 flex-col items-center bg-background",
44
+ showDivider && "border-r border-separator",
45
+ className,
46
+ )}
47
+ >
48
+ {top == null ? null : (
49
+ <div className={cn("flex w-full flex-col items-center gap-1.5 px-0 pt-3", topClassName)}>
50
+ {top}
51
+ </div>
52
+ )}
53
+ <Body
54
+ className={cn(
55
+ "flex w-full flex-1 flex-col items-center gap-1.5 overflow-y-auto py-2",
56
+ bodyClassName,
57
+ )}
58
+ >
59
+ {children}
60
+ </Body>
61
+ {footer == null ? null : (
62
+ <div className={cn("flex w-full flex-col items-center gap-1.5 py-3", footerClassName)}>
63
+ {footer}
64
+ </div>
65
+ )}
66
+ </div>
67
+ );
68
+ }
69
+
70
+ export type RailTileProps = Omit<ComponentPropsWithoutRef<"button">, "children"> & {
71
+ /** `aria-label`, and the tooltip unless `tooltip` is set. */
72
+ label: string;
73
+ /** Active state. With `asChild`, also sets `aria-current="page"`. */
74
+ active?: boolean;
75
+ /** Tooltip text. Default `label`. */
76
+ tooltip?: string;
77
+ /** Default `true`. */
78
+ showTooltip?: boolean;
79
+ /** Rendered after the children, inside the tile (for example a count with `railBadgeClass`). */
80
+ badge?: ReactNode;
81
+ /** Render the single child element (for example a router link) as the tile. */
82
+ asChild?: boolean;
83
+ children: ReactNode;
84
+ };
85
+
86
+ /** Rail tile with a right-side tooltip. Renders a ghost `Button`, or its child with `asChild`. */
87
+ export function RailTile({
88
+ label,
89
+ active = false,
90
+ tooltip,
91
+ showTooltip = true,
92
+ badge,
93
+ asChild = false,
94
+ className,
95
+ children,
96
+ ...props
97
+ }: RailTileProps) {
98
+ return (
99
+ <SidebarIconTooltip label={tooltip ?? label} enabled={showTooltip}>
100
+ {asChild ? (
101
+ <Slot
102
+ {...props}
103
+ aria-label={label}
104
+ aria-current={active ? "page" : undefined}
105
+ className={cn(railTileClass, "group", railTileStateClass(active), className)}
106
+ >
107
+ <Slottable>{children}</Slottable>
108
+ {badge}
109
+ </Slot>
110
+ ) : (
111
+ <Button
112
+ type="button"
113
+ variant="ghost"
114
+ {...props}
115
+ aria-label={label}
116
+ className={cn(railTileClass, "group h-11 w-11 p-0", railTileStateClass(active), className)}
117
+ >
118
+ {children}
119
+ {badge}
120
+ </Button>
121
+ )}
122
+ </SidebarIconTooltip>
123
+ );
124
+ }
125
+
126
+ export type RailDividerProps = { className?: string };
127
+
128
+ export function RailDivider({ className }: RailDividerProps) {
129
+ return <div className={cn("h-px w-8 bg-separator", className)} aria-hidden />;
130
+ }
@@ -0,0 +1,93 @@
1
+ import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react";
2
+ import { Button } from "../../src/ui/button";
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "../../src/ui/dialog";
11
+ import { cn } from "../../src/utils/cn";
12
+ import { signOutCancelClass, signOutDescriptionClass } from "./app-shell-classes";
13
+
14
+ export type SignOutDialogProps = {
15
+ open: boolean;
16
+ /** Not called with `false` while `pending`, so the dialog stays open during sign-out. */
17
+ onOpenChange: (open: boolean) => void;
18
+ /** Disables both buttons and shows "Signing out...". */
19
+ pending: boolean;
20
+ onConfirm: () => void | Promise<void>;
21
+ /** Second line under the question. */
22
+ description?: string;
23
+ /** Merged onto the dialog content after `sm:max-w-sm`. */
24
+ contentClassName?: string;
25
+ /** Dialog title and confirm button label. Default `"Sign out"`. */
26
+ title?: string;
27
+ /** Default `"Sign out of your account?"`. */
28
+ question?: string;
29
+ };
30
+
31
+ /** Sign-out confirmation. The app owns the state and the sign-out call. */
32
+ export function SignOutDialog({
33
+ open,
34
+ onOpenChange,
35
+ pending,
36
+ onConfirm,
37
+ description,
38
+ contentClassName,
39
+ title = "Sign out",
40
+ question = "Sign out of your account?",
41
+ }: SignOutDialogProps) {
42
+ return (
43
+ <Dialog
44
+ open={open}
45
+ onOpenChange={(next) => {
46
+ if (!next && pending) return;
47
+ onOpenChange(next);
48
+ }}
49
+ >
50
+ <DialogContent className={cn("sm:max-w-sm", contentClassName)}>
51
+ <DialogHeader>
52
+ <DialogTitle className="text-foreground">{title}</DialogTitle>
53
+ <DialogDescription className="sr-only">
54
+ Confirm signing out of your account
55
+ </DialogDescription>
56
+ </DialogHeader>
57
+ <div className="flex items-start gap-3 py-4">
58
+ <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-danger/10">
59
+ <IconAlertTriangle size={20} className="text-danger" />
60
+ </div>
61
+ <div>
62
+ <p className="text-foreground">{question}</p>
63
+ {description ? <p className={signOutDescriptionClass}>{description}</p> : null}
64
+ </div>
65
+ </div>
66
+ <DialogFooter>
67
+ <Button
68
+ variant="ghost"
69
+ onClick={() => onOpenChange(false)}
70
+ disabled={pending}
71
+ className={signOutCancelClass}
72
+ >
73
+ Cancel
74
+ </Button>
75
+ <Button
76
+ onClick={() => void onConfirm()}
77
+ disabled={pending}
78
+ className="bg-danger text-danger-foreground"
79
+ >
80
+ {pending ? (
81
+ <>
82
+ <IconLoader2 size={16} className="animate-spin" />
83
+ Signing out...
84
+ </>
85
+ ) : (
86
+ title
87
+ )}
88
+ </Button>
89
+ </DialogFooter>
90
+ </DialogContent>
91
+ </Dialog>
92
+ );
93
+ }
@@ -0,0 +1,44 @@
1
+ import { cn } from "../../src/utils/cn";
2
+
3
+ // Class strings for the app shell, copied from existing app markup so an app
4
+ // that moves to these components renders the same classes. The bare muted and
5
+ // accent colour names match that markup and are allow-listed for check:tokens
6
+ // on the marked lines only.
7
+
8
+ /** Rail tile: size-11, rounded-lg, press scale. */
9
+ export const railTileClass =
10
+ "relative flex size-11 items-center justify-center rounded-lg border active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring";
11
+
12
+ const railTileActiveClass = "border-transparent bg-surface-tertiary text-foreground";
13
+ const railTileIdleClass = "border-transparent text-muted opacity-75 hover:bg-surface-tertiary/50 hover:opacity-100 hover:text-foreground"; // check-tokens-allow: text-muted
14
+
15
+ export function railTileStateClass(active: boolean): string {
16
+ return active ? railTileActiveClass : railTileIdleClass;
17
+ }
18
+
19
+ /** Count badge pinned to the bottom-right of a rail tile. */
20
+ export const railBadgeClass = "absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold leading-none text-accent-foreground"; // check-tokens-allow: accent
21
+
22
+ /** Static section label, sentence case, padded to match nav rows. */
23
+ export const sidebarSectionLabelClass =
24
+ "sidebar-section-label px-3 py-1 text-[11px] font-medium tracking-[-0.01em]";
25
+
26
+ /** Nav link text: muted until active. */
27
+ export function sidebarNavLinkTextClass(isActive: boolean): string {
28
+ return cn(isActive ? "text-foreground" : "text-muted hover:text-foreground"); // check-tokens-allow: text-muted
29
+ }
30
+
31
+ /** Padding for main sidebar nav rows. */
32
+ export function sidebarNavRowClass(isIconOnly: boolean): string {
33
+ return cn(isIconOnly ? "justify-center px-0 py-1.5" : "gap-3 px-3 py-1.5");
34
+ }
35
+
36
+ export const mobileMenuButtonClass = "-ml-1 h-11 w-11 shrink-0 rounded-lg text-muted hover:bg-surface-tertiary/50 hover:text-foreground"; // check-tokens-allow: text-muted
37
+
38
+ export const panelCloseButtonClass = "rounded-lg text-muted hover:bg-surface-tertiary/50 hover:text-foreground"; // check-tokens-allow: text-muted
39
+
40
+ export const accountEmailClass = "block truncate text-xs font-normal text-muted"; // check-tokens-allow: text-muted
41
+
42
+ export const signOutDescriptionClass = "mt-1 text-sm text-muted"; // check-tokens-allow: text-muted
43
+
44
+ export const signOutCancelClass = "text-muted"; // check-tokens-allow: text-muted
@@ -10,3 +10,28 @@ export type {
10
10
  SessionsHoverCardProps,
11
11
  SessionsHoverCardSurface,
12
12
  } from "./SessionsHoverCard.js";
13
+ export { AppShell } from "./AppShell.js";
14
+ export type { AppShellProps } from "./AppShell.js";
15
+ export { AppSidebar } from "./AppSidebar.js";
16
+ export type { AppSidebarProps } from "./AppSidebar.js";
17
+ export { SidebarRail, RailTile, RailDivider } from "./SidebarRail.js";
18
+ export type { SidebarRailProps, RailTileProps, RailDividerProps } from "./SidebarRail.js";
19
+ export { SidebarIconTooltip } from "./SidebarIconTooltip.js";
20
+ export type { SidebarIconTooltipProps } from "./SidebarIconTooltip.js";
21
+ export { SidebarPanel } from "./SidebarPanel.js";
22
+ export type { SidebarPanelProps } from "./SidebarPanel.js";
23
+ export { SidebarAccountMenu, SidebarAccountMenuPlaceholder } from "./SidebarAccountMenu.js";
24
+ export type {
25
+ SidebarAccountMenuProps,
26
+ SidebarAccountMenuPlaceholderProps,
27
+ } from "./SidebarAccountMenu.js";
28
+ export { SignOutDialog } from "./SignOutDialog.js";
29
+ export type { SignOutDialogProps } from "./SignOutDialog.js";
30
+ export {
31
+ railBadgeClass,
32
+ railTileClass,
33
+ railTileStateClass,
34
+ sidebarNavLinkTextClass,
35
+ sidebarNavRowClass,
36
+ sidebarSectionLabelClass,
37
+ } from "./app-shell-classes.js";
@@ -1,29 +1,47 @@
1
1
  import type { ReactNode } from "react";
2
- import { AnimatePresence, domAnimation, LazyMotion, m } from "motion/react";
2
+ import { AnimatePresence, domAnimation, LazyMotion, m, type Variants } from "motion/react";
3
3
  import { motionFast } from "./motion";
4
4
 
5
5
  export type FadePresenceProps = {
6
6
  show: boolean;
7
7
  children: ReactNode;
8
8
  className?: string;
9
+ /**
10
+ * Optional `hidden` / `show` / `exit` variants, for example `fadeUpSpring`.
11
+ * Omitted, the content fades opacity only.
12
+ */
13
+ variants?: Variants;
9
14
  };
10
15
 
11
16
  /** Fades children in and out as `show` changes. Does not animate on first mount. Brings its own `LazyMotion`, so `m` components animate without an app provider. */
12
- export function FadePresence({ show, children, className }: FadePresenceProps) {
17
+ export function FadePresence({ show, children, className, variants }: FadePresenceProps) {
13
18
  return (
14
19
  <LazyMotion features={domAnimation}>
15
20
  <AnimatePresence initial={false}>
16
21
  {show ? (
17
- <m.div
18
- key="fade-presence"
19
- className={className}
20
- initial={{ opacity: 0 }}
21
- animate={{ opacity: 1 }}
22
- exit={{ opacity: 0 }}
23
- transition={motionFast}
24
- >
25
- {children}
26
- </m.div>
22
+ variants ? (
23
+ <m.div
24
+ key="fade-presence"
25
+ className={className}
26
+ initial="hidden"
27
+ animate="show"
28
+ exit="exit"
29
+ variants={variants}
30
+ >
31
+ {children}
32
+ </m.div>
33
+ ) : (
34
+ <m.div
35
+ key="fade-presence"
36
+ className={className}
37
+ initial={{ opacity: 0 }}
38
+ animate={{ opacity: 1 }}
39
+ exit={{ opacity: 0 }}
40
+ transition={motionFast}
41
+ >
42
+ {children}
43
+ </m.div>
44
+ )
27
45
  ) : null}
28
46
  </AnimatePresence>
29
47
  </LazyMotion>
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ export { cn } from "./utils/cn";
4
4
  export { floatingSurfaceClass } from "./ui/_menu-classes";
5
5
  export * from "./ui/surface-classes";
6
6
  export * from "./motion/presets";
7
+ export * from "./motion/panel-transition";
8
+ export { restoreModalScrollLock } from "./ui/overlay-a11y";
7
9
 
8
10
  export * from "./ui/badge";
9
11
  export * from "./ui/breadcrumb";
@@ -40,6 +42,7 @@ export {
40
42
  } from "./ui/radix-select";
41
43
  export * from "./ui/search-input";
42
44
  export * from "./ui/select";
45
+ export * from "./ui/select-field";
43
46
  export * from "./ui/separator";
44
47
  export * from "./ui/skeleton";
45
48
  export * from "./ui/slide-over-panel";
@@ -117,6 +120,34 @@ export type {
117
120
  SessionsHoverCardSurface,
118
121
  } from "../shell/src/SessionsHoverCard";
119
122
 
123
+ // App shell: sidebar and main page layout. Widths come from the
124
+ // `--app-shell-*` CSS variables that `AppShell` sets.
125
+ export { AppShell } from "../shell/src/AppShell";
126
+ export type { AppShellProps } from "../shell/src/AppShell";
127
+ export { AppSidebar } from "../shell/src/AppSidebar";
128
+ export type { AppSidebarProps } from "../shell/src/AppSidebar";
129
+ export { SidebarRail, RailTile, RailDivider } from "../shell/src/SidebarRail";
130
+ export type { SidebarRailProps, RailTileProps, RailDividerProps } from "../shell/src/SidebarRail";
131
+ export { SidebarIconTooltip } from "../shell/src/SidebarIconTooltip";
132
+ export type { SidebarIconTooltipProps } from "../shell/src/SidebarIconTooltip";
133
+ export { SidebarPanel } from "../shell/src/SidebarPanel";
134
+ export type { SidebarPanelProps } from "../shell/src/SidebarPanel";
135
+ export { SidebarAccountMenu, SidebarAccountMenuPlaceholder } from "../shell/src/SidebarAccountMenu";
136
+ export type {
137
+ SidebarAccountMenuProps,
138
+ SidebarAccountMenuPlaceholderProps,
139
+ } from "../shell/src/SidebarAccountMenu";
140
+ export { SignOutDialog } from "../shell/src/SignOutDialog";
141
+ export type { SignOutDialogProps } from "../shell/src/SignOutDialog";
142
+ export {
143
+ railBadgeClass,
144
+ railTileClass,
145
+ railTileStateClass,
146
+ sidebarNavLinkTextClass,
147
+ sidebarNavRowClass,
148
+ sidebarSectionLabelClass,
149
+ } from "../shell/src/app-shell-classes";
150
+
120
151
  // App helpers: confirm skip, update watcher, settings chrome, stable auth.
121
152
  export * from "../app/src/index";
122
153
 
@@ -0,0 +1,38 @@
1
+ import * as React from "react";
2
+ import type { ComponentType, ReactNode } from "react";
3
+
4
+ /** The `ViewTransition` props used here. Typed locally so consumers on `@types/react` without `ViewTransition` still typecheck. */
5
+ type ViewTransitionLikeProps = {
6
+ children?: ReactNode;
7
+ default?: string;
8
+ enter?: string;
9
+ exit?: string;
10
+ };
11
+
12
+ const ViewTransition: ComponentType<ViewTransitionLikeProps> = Reflect.get(React, "ViewTransition");
13
+
14
+ export interface PanelTransitionProps {
15
+ children?: ReactNode;
16
+ /** View transition class on enter. */
17
+ enter?: string;
18
+ /** View transition class on exit. */
19
+ exit?: string;
20
+ }
21
+
22
+ /**
23
+ * Enter and exit view transition for a panel; updates inside it do not animate.
24
+ * Needs a React build that exports `ViewTransition`. The app supplies the
25
+ * `::view-transition-*` CSS for the class names (`vt-panel-in` and
26
+ * `vt-panel-out` by default).
27
+ */
28
+ export function PanelTransition({
29
+ children,
30
+ enter = "vt-panel-in",
31
+ exit = "vt-panel-out",
32
+ }: PanelTransitionProps) {
33
+ return (
34
+ <ViewTransition default="none" enter={enter} exit={exit}>
35
+ {children}
36
+ </ViewTransition>
37
+ );
38
+ }
@@ -33,3 +33,21 @@ export const fadeUp: Variants = {
33
33
  transition: { duration: motionDuration.fast, ease: motionEase },
34
34
  },
35
35
  };
36
+
37
+ export const springTransition: Transition = {
38
+ type: "spring",
39
+ bounce: 0,
40
+ duration: 0.35,
41
+ };
42
+
43
+ export const springFastTransition: Transition = {
44
+ type: "spring",
45
+ bounce: 0,
46
+ duration: 0.25,
47
+ };
48
+
49
+ export const fadeUpSpring: Variants = {
50
+ hidden: { opacity: 0, y: motionDistance.pageY },
51
+ show: { opacity: 1, y: 0, transition: springTransition },
52
+ exit: { opacity: 0, y: 6, transition: springFastTransition },
53
+ };
@@ -0,0 +1,59 @@
1
+ import type * as React from "react";
2
+
3
+ /** `--vv-height` is optional: apps may set it to the visual viewport height. */
4
+ export const fitViewportClass =
5
+ "max-sm:max-h-[min(70dvh,var(--vv-height,70dvh))] max-sm:overflow-y-auto";
6
+
7
+ /** Wraps a key handler so a bare Alt press does not move focus out of the menu. */
8
+ export function suppressAltKeyDown<E extends HTMLElement>(
9
+ onKeyDown: React.KeyboardEventHandler<E> | undefined,
10
+ ): React.KeyboardEventHandler<E> {
11
+ return (event) => {
12
+ if (event.key === "Alt") event.preventDefault();
13
+ onKeyDown?.(event);
14
+ };
15
+ }
16
+
17
+ /** Item row with a growing label column, used once `endContent` (or context-menu slots) are set. */
18
+ export function MenuItemRow({
19
+ startContent,
20
+ description,
21
+ endContent,
22
+ children,
23
+ }: {
24
+ startContent?: React.ReactNode;
25
+ description?: React.ReactNode;
26
+ endContent?: React.ReactNode;
27
+ children?: React.ReactNode;
28
+ }) {
29
+ return (
30
+ <>
31
+ {startContent}
32
+ <div className="min-w-0 flex-1 text-left">
33
+ <div className="flex items-center gap-2">{children}</div>
34
+ {description != null ? (
35
+ <p className="text-xs text-muted-foreground">{description}</p>
36
+ ) : null}
37
+ </div>
38
+ {endContent}
39
+ </>
40
+ );
41
+ }
42
+
43
+ /** Sub-trigger label with a leading slot; used only when `startContent` is set. */
44
+ export function SubTriggerRow({
45
+ startContent,
46
+ children,
47
+ }: {
48
+ startContent: React.ReactNode;
49
+ children?: React.ReactNode;
50
+ }) {
51
+ return (
52
+ <>
53
+ {startContent}
54
+ <div className="flex min-w-0 flex-1 items-center gap-2 text-left">
55
+ {children}
56
+ </div>
57
+ </>
58
+ );
59
+ }
package/src/ui/button.tsx CHANGED
@@ -3,6 +3,9 @@ import { Slot } from "@radix-ui/react-slot";
3
3
  import { cva, type VariantProps } from "class-variance-authority";
4
4
  import { cn } from "../utils/cn";
5
5
 
6
+ const ghostClass =
7
+ "text-muted-foreground hover:bg-surface-tertiary hover:text-foreground";
8
+
6
9
  const buttonVariants = cva(
7
10
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium tracking-normal transition-[transform,background-color,box-shadow,opacity,border-color] duration-200 ease-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.96] [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0",
8
11
  {
@@ -15,8 +18,12 @@ const buttonVariants = cva(
15
18
  outline:
16
19
  "border border-border bg-transparent text-foreground hover:bg-default",
17
20
  secondary: "bg-default text-default-foreground hover:bg-default/78",
18
- ghost: "text-muted-foreground hover:bg-surface-tertiary hover:text-foreground",
21
+ ghost: ghostClass,
19
22
  link: "text-primary underline-offset-4 hover:underline active:scale-100",
23
+ // Ghost plus an override; tailwind-merge drops the clashing ghost classes.
24
+ flat: `${ghostClass} bg-transparent text-muted hover:bg-surface-tertiary hover:text-foreground`, // check-tokens-allow: text-muted
25
+ success: `${ghostClass} bg-success text-success-foreground hover:bg-success/90`,
26
+ warning: `${ghostClass} bg-warning text-warning-foreground hover:bg-warning/90`,
20
27
  },
21
28
  size: {
22
29
  default: "h-10 px-4 py-2",
@@ -41,21 +48,39 @@ export interface ButtonProps
41
48
  asChild?: boolean;
42
49
  /** Rendered before the children. Ignored with `asChild`. */
43
50
  startContent?: React.ReactNode;
51
+ /** Rendered after the children. Ignored with `asChild`. */
52
+ endContent?: React.ReactNode;
53
+ /** Disables the button, sets `data-loading`, and shows a spinner in place of `startContent`. */
54
+ loading?: boolean;
55
+ }
56
+
57
+ function ButtonSpinner() {
58
+ return (
59
+ <svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
60
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
61
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c3.042 1.135 5.824 3 7.938l3-2.647z" />
62
+ </svg>
63
+ );
44
64
  }
45
65
 
46
66
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
47
- ({ className, variant, size, asChild = false, startContent, children, ...props }, ref) => {
67
+ (
68
+ { className, variant, size, asChild = false, startContent, endContent, loading, children, ...props },
69
+ ref,
70
+ ) => {
48
71
  const Comp = asChild ? Slot : "button";
49
72
  return (
50
73
  <Comp
51
74
  className={cn(buttonVariants({ variant, size, className }))}
52
75
  ref={ref}
53
76
  {...props}
77
+ {...(loading ? { disabled: true, "data-loading": "true" } : null)}
54
78
  >
55
79
  {asChild ? children : (
56
80
  <>
57
- {startContent}
81
+ {loading ? <ButtonSpinner /> : startContent}
58
82
  {children}
83
+ {endContent}
59
84
  </>
60
85
  )}
61
86
  </Comp>
package/src/ui/card.tsx CHANGED
@@ -16,6 +16,18 @@ const Card = React.forwardRef<
16
16
  ));
17
17
  Card.displayName = "Card";
18
18
 
19
+ const CardHeader = React.forwardRef<
20
+ HTMLDivElement,
21
+ React.HTMLAttributes<HTMLDivElement>
22
+ >(({ className, ...props }, ref) => (
23
+ <div
24
+ ref={ref}
25
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
26
+ {...props}
27
+ />
28
+ ));
29
+ CardHeader.displayName = "CardHeader";
30
+
19
31
  const CardContent = React.forwardRef<
20
32
  HTMLDivElement,
21
33
  React.HTMLAttributes<HTMLDivElement>
@@ -24,4 +36,4 @@ const CardContent = React.forwardRef<
24
36
  ));
25
37
  CardContent.displayName = "CardContent";
26
38
 
27
- export { Card, CardContent };
39
+ export { Card, CardHeader, CardContent };