@cronus-ui/ui 0.6.0 → 0.6.1

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,62 @@
1
+ import { type ComponentPropsWithoutRef, type ReactNode, type Ref } from "react";
2
+ import { DialogContent } from "./dialog.js";
3
+ export interface InviteDialogLabels {
4
+ title?: string;
5
+ description?: string;
6
+ email?: string;
7
+ emailPlaceholder?: string;
8
+ role?: string;
9
+ send?: string;
10
+ cancel?: string;
11
+ sending?: string;
12
+ errorFallback?: string;
13
+ }
14
+ export interface InviteRole {
15
+ value: string;
16
+ label: string;
17
+ }
18
+ export interface InviteDialogProps extends Omit<ComponentPropsWithoutRef<typeof DialogContent>, "title" | "children"> {
19
+ /** Element that opens the dialog. Rendered inside a `DialogTrigger asChild`. Omit when driving the dialog purely via `open`. */
20
+ trigger?: ReactNode;
21
+ /** Controlled open state. Provide alongside `onOpenChange` to control the dialog externally. */
22
+ open?: boolean;
23
+ /** Uncontrolled initial open state. @default false */
24
+ defaultOpen?: boolean;
25
+ /** Called whenever the open state changes. Dismissal is suppressed while an invite promise is pending. */
26
+ onOpenChange?: (open: boolean) => void;
27
+ /** Roles offered in the select. Defaults to Member and Admin. */
28
+ roles?: readonly InviteRole[];
29
+ /** Initially selected role `value`. Falls back to the first role. */
30
+ defaultRole?: string;
31
+ /**
32
+ * Invoked with the submitted email and role. If it returns a promise, the send
33
+ * button shows a spinner and actions are disabled until it settles; on resolve
34
+ * the dialog closes, on reject it stays open and surfaces the error.
35
+ */
36
+ onInvite?: (payload: {
37
+ email: string;
38
+ role: string;
39
+ }) => void | Promise<void>;
40
+ /** Override any subset of the built-in English strings. */
41
+ labels?: InviteDialogLabels;
42
+ /** Forwarded to the dialog content surface. */
43
+ ref?: Ref<HTMLDivElement>;
44
+ }
45
+ /**
46
+ * An ergonomic invite dialog for adding a member by email and role, composed
47
+ * on top of the accessible `Dialog` primitive (focus trap, overlay, Escape).
48
+ *
49
+ * Behavior: `onInvite` may be async — while its promise is pending the send
50
+ * button shows a `Spinner`, both actions are disabled, and the dialog cannot be
51
+ * dismissed so the operation can't be interrupted mid-flight. On resolve the
52
+ * dialog closes; on reject it stays open and renders the error in a
53
+ * `role="alert"` region. Works controlled (`open`/`onOpenChange`) or
54
+ * uncontrolled (`defaultOpen`).
55
+ *
56
+ * Performance/a11y: late promise settlements are ignored after unmount via a
57
+ * mounted ref. The spinner is decorative (`aria-hidden`) with loading state
58
+ * conveyed through `aria-busy`. All entrance/exit motion is owned by the
59
+ * underlying primitive and degrades under `prefers-reduced-motion`.
60
+ */
61
+ export declare function InviteDialog({ trigger, open, defaultOpen, onOpenChange, roles, defaultRole, onInvite, labels, className, ref, ...contentProps }: InviteDialogProps): import("react").JSX.Element;
62
+ //# sourceMappingURL=invite-dialog.d.ts.map
@@ -0,0 +1,141 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { CircleAlert } from "lucide-react";
4
+ import { useCallback, useEffect, useId, useRef, useState, } from "react";
5
+ import { cn } from "../lib/cn.js";
6
+ import { Button } from "./button.js";
7
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "./dialog.js";
8
+ import { Field, FieldLabel } from "./field.js";
9
+ import { Input } from "./input.js";
10
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select.js";
11
+ import { Spinner } from "./spinner.js";
12
+ const DEFAULT_LABELS = {
13
+ title: "Invite member",
14
+ description: "Send an invitation to join this workspace.",
15
+ email: "Email",
16
+ emailPlaceholder: "name@example.com",
17
+ role: "Role",
18
+ send: "Send invite",
19
+ cancel: "Cancel",
20
+ sending: "Sending",
21
+ errorFallback: "Something went wrong. Please try again.",
22
+ };
23
+ const DEFAULT_ROLES = [
24
+ { value: "member", label: "Member" },
25
+ { value: "admin", label: "Admin" },
26
+ ];
27
+ function resolveErrorMessage(error, fallback) {
28
+ if (error instanceof Error && error.message) {
29
+ return error.message;
30
+ }
31
+ if (typeof error === "string" && error) {
32
+ return error;
33
+ }
34
+ return fallback;
35
+ }
36
+ function resolveRoles(roles) {
37
+ return roles && roles.length > 0 ? roles : DEFAULT_ROLES;
38
+ }
39
+ function resolveDefaultRole(roles, defaultRole) {
40
+ if (defaultRole && roles.some((role) => role.value === defaultRole)) {
41
+ return defaultRole;
42
+ }
43
+ return roles[0]?.value ?? "";
44
+ }
45
+ /**
46
+ * An ergonomic invite dialog for adding a member by email and role, composed
47
+ * on top of the accessible `Dialog` primitive (focus trap, overlay, Escape).
48
+ *
49
+ * Behavior: `onInvite` may be async — while its promise is pending the send
50
+ * button shows a `Spinner`, both actions are disabled, and the dialog cannot be
51
+ * dismissed so the operation can't be interrupted mid-flight. On resolve the
52
+ * dialog closes; on reject it stays open and renders the error in a
53
+ * `role="alert"` region. Works controlled (`open`/`onOpenChange`) or
54
+ * uncontrolled (`defaultOpen`).
55
+ *
56
+ * Performance/a11y: late promise settlements are ignored after unmount via a
57
+ * mounted ref. The spinner is decorative (`aria-hidden`) with loading state
58
+ * conveyed through `aria-busy`. All entrance/exit motion is owned by the
59
+ * underlying primitive and degrades under `prefers-reduced-motion`.
60
+ */
61
+ export function InviteDialog({ trigger, open, defaultOpen, onOpenChange, roles, defaultRole, onInvite, labels, className, ref, ...contentProps }) {
62
+ const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
63
+ const resolvedRoles = resolveRoles(roles);
64
+ const initialRole = resolveDefaultRole(resolvedRoles, defaultRole);
65
+ const isControlled = open !== undefined;
66
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen ?? false);
67
+ const isOpen = isControlled ? open : uncontrolledOpen;
68
+ const [email, setEmail] = useState("");
69
+ const [role, setRole] = useState(initialRole);
70
+ const [loading, setLoading] = useState(false);
71
+ const [error, setError] = useState(null);
72
+ const loadingRef = useRef(false);
73
+ const mountedRef = useRef(true);
74
+ const emailId = useId();
75
+ const roleId = useId();
76
+ useEffect(() => {
77
+ mountedRef.current = true;
78
+ return () => {
79
+ mountedRef.current = false;
80
+ };
81
+ }, []);
82
+ const setOpen = useCallback((next) => {
83
+ if (!isControlled) {
84
+ setUncontrolledOpen(next);
85
+ }
86
+ onOpenChange?.(next);
87
+ }, [isControlled, onOpenChange]);
88
+ const handleOpenChange = useCallback((next) => {
89
+ if (loadingRef.current && !next) {
90
+ return;
91
+ }
92
+ if (next) {
93
+ setError(null);
94
+ setEmail("");
95
+ setRole(initialRole);
96
+ }
97
+ setOpen(next);
98
+ }, [initialRole, setOpen]);
99
+ const beginLoading = useCallback((value) => {
100
+ loadingRef.current = value;
101
+ setLoading(value);
102
+ }, []);
103
+ const handleSubmit = useCallback(async (event) => {
104
+ event.preventDefault();
105
+ if (loadingRef.current) {
106
+ return;
107
+ }
108
+ const trimmed = email.trim();
109
+ if (!trimmed) {
110
+ return;
111
+ }
112
+ setError(null);
113
+ if (!onInvite) {
114
+ setOpen(false);
115
+ return;
116
+ }
117
+ const result = onInvite({ email: trimmed, role });
118
+ if (!(result instanceof Promise)) {
119
+ setOpen(false);
120
+ return;
121
+ }
122
+ beginLoading(true);
123
+ try {
124
+ await result;
125
+ if (!mountedRef.current) {
126
+ return;
127
+ }
128
+ beginLoading(false);
129
+ setOpen(false);
130
+ }
131
+ catch (err) {
132
+ if (!mountedRef.current) {
133
+ return;
134
+ }
135
+ beginLoading(false);
136
+ setError(resolveErrorMessage(err, resolvedLabels.errorFallback));
137
+ }
138
+ }, [beginLoading, email, onInvite, resolvedLabels.errorFallback, role, setOpen]);
139
+ return (_jsxs(Dialog, { open: isOpen, onOpenChange: handleOpenChange, children: [trigger ? _jsx(DialogTrigger, { asChild: true, children: trigger }) : null, _jsxs(DialogContent, { ref: ref, "data-slot": "invite-dialog", className: cn("max-w-md", className), ...contentProps, children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: resolvedLabels.title }), resolvedLabels.description ? (_jsx(DialogDescription, { children: resolvedLabels.description })) : null] }), _jsxs("form", { className: "flex flex-col gap-4", onSubmit: handleSubmit, children: [_jsxs(Field, { children: [_jsx(FieldLabel, { htmlFor: emailId, children: resolvedLabels.email }), _jsx(Input, { id: emailId, type: "email", name: "email", autoComplete: "email", required: true, value: email, onChange: (event) => setEmail(event.target.value), placeholder: resolvedLabels.emailPlaceholder, disabled: loading })] }), _jsxs(Field, { children: [_jsx(FieldLabel, { htmlFor: roleId, children: resolvedLabels.role }), _jsxs(Select, { value: role, onValueChange: setRole, disabled: loading, children: [_jsx(SelectTrigger, { id: roleId, children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: resolvedRoles.map((item) => (_jsx(SelectItem, { value: item.value, children: item.label }, item.value))) })] })] }), error ? (_jsxs("div", { role: "alert", "data-slot": "invite-dialog-error", className: "flex items-start gap-2 rounded-lg border border-error/30 bg-error/10 px-3 py-2 text-start text-sm text-error-strong", children: [_jsx(CircleAlert, { "aria-hidden": true, className: "mt-0.5 size-4 shrink-0" }), _jsx("span", { children: error })] })) : null, _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", disabled: loading, onClick: () => handleOpenChange(false), children: resolvedLabels.cancel }), _jsxs(Button, { type: "submit", "data-slot": "invite-dialog-send", disabled: loading, "aria-busy": loading, className: "min-w-24", children: [loading ? _jsx(Spinner, { size: "sm", "aria-hidden": true }) : null, loading ? resolvedLabels.sending : resolvedLabels.send] })] })] })] })] }));
140
+ }
141
+ //# sourceMappingURL=invite-dialog.js.map
@@ -0,0 +1,36 @@
1
+ import { type Ref } from "react";
2
+ export interface WorkspaceItem {
3
+ id: string;
4
+ name: string;
5
+ initials?: string;
6
+ image?: string;
7
+ }
8
+ export interface WorkspaceSwitcherLabels {
9
+ /** Accessible name for the trigger. @default "Switch workspace" */
10
+ label?: string;
11
+ }
12
+ export interface WorkspaceSwitcherProps {
13
+ workspaces: readonly WorkspaceItem[];
14
+ /** Controlled selected workspace id. Pair with `onValueChange`. */
15
+ value?: string;
16
+ /** Uncontrolled initial workspace id. Defaults to the first workspace. */
17
+ defaultValue?: string;
18
+ /** Called with the selected workspace id. */
19
+ onValueChange?: (id: string) => void;
20
+ /** Override any subset of the built-in English strings. */
21
+ labels?: WorkspaceSwitcherLabels;
22
+ className?: string;
23
+ /** Forwarded to the trigger button. */
24
+ ref?: Ref<HTMLButtonElement>;
25
+ }
26
+ /**
27
+ * Compact workspace/org switcher for app chrome. The trigger shows the current
28
+ * workspace avatar, name, and a chevron; the menu lists every workspace with
29
+ * the selected row marked as a radio item (`menuitemradio`).
30
+ *
31
+ * Controlled via `value`/`onValueChange` or uncontrolled via `defaultValue`.
32
+ * Renders nothing when `workspaces` is empty. Keyboard access is owned by the
33
+ * underlying (non-modal) dropdown menu.
34
+ */
35
+ export declare function WorkspaceSwitcher({ workspaces, value, defaultValue, onValueChange, labels, className, ref, }: WorkspaceSwitcherProps): import("react").JSX.Element | null;
36
+ //# sourceMappingURL=workspace-switcher.d.ts.map
@@ -0,0 +1,56 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ChevronsUpDown } from "lucide-react";
4
+ import { useCallback, useState } from "react";
5
+ import { cn } from "../lib/cn.js";
6
+ import { Avatar, AvatarFallback, AvatarImage } from "./avatar.js";
7
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "./dropdown-menu.js";
8
+ const DEFAULT_LABELS = {
9
+ label: "Switch workspace",
10
+ };
11
+ function workspaceInitials(workspace) {
12
+ if (workspace.initials) {
13
+ return workspace.initials;
14
+ }
15
+ const parts = workspace.name.trim().split(/\s+/).filter(Boolean);
16
+ if (parts.length >= 2) {
17
+ const first = parts[0]?.[0] ?? "";
18
+ const second = parts[1]?.[0] ?? "";
19
+ return `${first}${second}`.toUpperCase();
20
+ }
21
+ return workspace.name.slice(0, 2).toUpperCase();
22
+ }
23
+ /**
24
+ * Compact workspace/org switcher for app chrome. The trigger shows the current
25
+ * workspace avatar, name, and a chevron; the menu lists every workspace with
26
+ * the selected row marked as a radio item (`menuitemradio`).
27
+ *
28
+ * Controlled via `value`/`onValueChange` or uncontrolled via `defaultValue`.
29
+ * Renders nothing when `workspaces` is empty. Keyboard access is owned by the
30
+ * underlying (non-modal) dropdown menu.
31
+ */
32
+ export function WorkspaceSwitcher({ workspaces, value, defaultValue, onValueChange, labels, className, ref, }) {
33
+ const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
34
+ const isControlled = value !== undefined;
35
+ const [uncontrolled, setUncontrolled] = useState(() => defaultValue ?? workspaces[0]?.id ?? "");
36
+ const selectedId = isControlled ? value : uncontrolled;
37
+ const handleChange = useCallback((id) => {
38
+ if (!isControlled) {
39
+ setUncontrolled(id);
40
+ }
41
+ onValueChange?.(id);
42
+ }, [isControlled, onValueChange]);
43
+ if (workspaces.length === 0) {
44
+ return null;
45
+ }
46
+ const current = workspaces.find((workspace) => workspace.id === selectedId) ?? workspaces[0];
47
+ if (!current) {
48
+ return null;
49
+ }
50
+ const triggerLabel = `${resolvedLabels.label}, ${current.name}`;
51
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { ref: ref, type: "button", "data-slot": "workspace-switcher", "aria-label": triggerLabel, className: cn("flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-fg outline-none hover:bg-surface-overlay focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-base disabled:pointer-events-none disabled:opacity-50", className), children: [_jsx(WorkspaceAvatar, { workspace: current }), _jsx("span", { className: "min-w-0 flex-1 truncate text-start font-medium", children: current.name }), _jsx(ChevronsUpDown, { className: "size-4 shrink-0 text-fg-tertiary", "aria-hidden": true })] }) }), _jsx(DropdownMenuContent, { "data-slot": "workspace-switcher-content", align: "start", className: "min-w-56", children: _jsx(DropdownMenuRadioGroup, { value: current.id, onValueChange: handleChange, children: workspaces.map((workspace) => (_jsxs(DropdownMenuRadioItem, { value: workspace.id, className: "gap-2", children: [_jsx(WorkspaceAvatar, { workspace: workspace }), _jsx("span", { className: "min-w-0 flex-1 truncate text-start", children: workspace.name })] }, workspace.id))) }) })] }));
52
+ }
53
+ function WorkspaceAvatar({ workspace }) {
54
+ return (_jsxs(Avatar, { className: "size-6", children: [workspace.image ? _jsx(AvatarImage, { src: workspace.image, alt: "" }) : null, _jsx(AvatarFallback, { className: "text-xs", children: workspaceInitials(workspace) })] }));
55
+ }
56
+ //# sourceMappingURL=workspace-switcher.js.map
package/dist/index.d.ts CHANGED
@@ -142,6 +142,8 @@ export { Input } from "./components/input.js";
142
142
  export type { InputGroupAddonProps, InputGroupProps } from "./components/input-group.js";
143
143
  export { InputGroup, InputGroupAddon } from "./components/input-group.js";
144
144
  export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, } from "./components/input-otp.js";
145
+ export type { InviteDialogLabels, InviteDialogProps, InviteRole, } from "./components/invite-dialog.js";
146
+ export { InviteDialog } from "./components/invite-dialog.js";
145
147
  export type { JsonViewerProps } from "./components/json-viewer.js";
146
148
  export { JsonViewer } from "./components/json-viewer.js";
147
149
  export type { KanbanColumn, KanbanItem, KanbanProps } from "./components/kanban.js";
@@ -302,5 +304,7 @@ export type { VideoPlayerLabels, VideoPlayerProps } from "./components/video-pla
302
304
  export { VideoPlayer, videoPlayerVariants } from "./components/video-player.js";
303
305
  export type { WordRotateProps } from "./components/word-rotate.js";
304
306
  export { WordRotate } from "./components/word-rotate.js";
307
+ export type { WorkspaceItem, WorkspaceSwitcherLabels, WorkspaceSwitcherProps, } from "./components/workspace-switcher.js";
308
+ export { WorkspaceSwitcher } from "./components/workspace-switcher.js";
305
309
  export { cn } from "./lib/cn.js";
306
310
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -85,6 +85,7 @@ export { ImageZoom, imageZoomVariants } from "./components/image-zoom.js";
85
85
  export { Input } from "./components/input.js";
86
86
  export { InputGroup, InputGroupAddon } from "./components/input-group.js";
87
87
  export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, } from "./components/input-otp.js";
88
+ export { InviteDialog } from "./components/invite-dialog.js";
88
89
  export { JsonViewer } from "./components/json-viewer.js";
89
90
  export { Kanban } from "./components/kanban.js";
90
91
  export { Kbd } from "./components/kbd.js";
@@ -175,5 +176,6 @@ export { TypingText } from "./components/typing-text.js";
175
176
  export { UsageMeter, UsageMeterCircular, UsageMeterLinear } from "./components/usage-meter.js";
176
177
  export { VideoPlayer, videoPlayerVariants } from "./components/video-player.js";
177
178
  export { WordRotate } from "./components/word-rotate.js";
179
+ export { WorkspaceSwitcher } from "./components/workspace-switcher.js";
178
180
  export { cn } from "./lib/cn.js";
179
181
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cronus-ui/ui",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Cronus UI — themeable, accessible React components (Radix + CVA + Tailwind v4).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -316,6 +316,10 @@
316
316
  "types": "./dist/components/input-otp.d.ts",
317
317
  "import": "./dist/components/input-otp.js"
318
318
  },
319
+ "./invite-dialog": {
320
+ "types": "./dist/components/invite-dialog.d.ts",
321
+ "import": "./dist/components/invite-dialog.js"
322
+ },
319
323
  "./json-viewer": {
320
324
  "types": "./dist/components/json-viewer.d.ts",
321
325
  "import": "./dist/components/json-viewer.js"
@@ -668,6 +672,10 @@
668
672
  "types": "./dist/components/word-rotate.d.ts",
669
673
  "import": "./dist/components/word-rotate.js"
670
674
  },
675
+ "./workspace-switcher": {
676
+ "types": "./dist/components/workspace-switcher.d.ts",
677
+ "import": "./dist/components/workspace-switcher.js"
678
+ },
671
679
  "./package.json": "./package.json"
672
680
  },
673
681
  "main": "./dist/index.js",
@@ -747,7 +755,7 @@
747
755
  "vaul": "^1.1.2"
748
756
  },
749
757
  "peerDependencies": {
750
- "@cronus-ui/theme": "0.6.0",
758
+ "@cronus-ui/theme": "0.6.1",
751
759
  "@dnd-kit/core": "^6.3.1",
752
760
  "@dnd-kit/sortable": "^10.0.0",
753
761
  "@dnd-kit/utilities": "^3.2.2",
@@ -891,7 +899,7 @@
891
899
  }
892
900
  },
893
901
  "devDependencies": {
894
- "@cronus-ui/theme": "0.6.0",
902
+ "@cronus-ui/theme": "0.6.1",
895
903
  "@dnd-kit/core": "^6.3.1",
896
904
  "@dnd-kit/sortable": "^10.0.0",
897
905
  "@dnd-kit/utilities": "^3.2.2",