@vvedantb/ui 0.5.4 → 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.
@@ -1,13 +1,5 @@
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";
1
+ import { IconAlertTriangle } from "@tabler/icons-react";
2
+ import { ConfirmDialog } from "../../src/ui/confirm-dialog";
11
3
  import { cn } from "../../src/utils/cn";
12
4
  import { signOutCancelClass, signOutDescriptionClass } from "./app-shell-classes";
13
5
 
@@ -22,13 +14,13 @@ export type SignOutDialogProps = {
22
14
  description?: string;
23
15
  /** Merged onto the dialog content after `sm:max-w-sm`. */
24
16
  contentClassName?: string;
25
- /** Dialog title and confirm button label. Default `"Sign out"`. */
17
+ /** Legacy (still supported): dialog title and confirm button label. Default `"Sign out"`. */
26
18
  title?: string;
27
- /** Default `"Sign out of your account?"`. */
19
+ /** Legacy (still supported): default `"Sign out of your account?"`. */
28
20
  question?: string;
29
21
  };
30
22
 
31
- /** Sign-out confirmation. The app owns the state and the sign-out call. */
23
+ /** Sign-out confirmation. The app owns the state and the sign-out call. Composed from `ConfirmDialog` parts. */
32
24
  export function SignOutDialog({
33
25
  open,
34
26
  onOpenChange,
@@ -40,21 +32,15 @@ export function SignOutDialog({
40
32
  question = "Sign out of your account?",
41
33
  }: SignOutDialogProps) {
42
34
  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">
35
+ <ConfirmDialog.Root open={open} onOpenChange={onOpenChange} alert={false} pending={pending}>
36
+ <ConfirmDialog.Content className={cn("sm:max-w-sm", contentClassName)}>
37
+ <ConfirmDialog.Header>
38
+ <ConfirmDialog.Title className="text-foreground">{title}</ConfirmDialog.Title>
39
+ <ConfirmDialog.Description className="sr-only">
54
40
  Confirm signing out of your account
55
- </DialogDescription>
56
- </DialogHeader>
57
- <div className="flex items-start gap-3 py-4">
41
+ </ConfirmDialog.Description>
42
+ </ConfirmDialog.Header>
43
+ <ConfirmDialog.Body className="flex items-start gap-3 py-4">
58
44
  <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-danger/10">
59
45
  <IconAlertTriangle size={20} className="text-danger" />
60
46
  </div>
@@ -62,32 +48,19 @@ export function SignOutDialog({
62
48
  <p className="text-foreground">{question}</p>
63
49
  {description ? <p className={signOutDescriptionClass}>{description}</p> : null}
64
50
  </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
51
+ </ConfirmDialog.Body>
52
+ <ConfirmDialog.Footer>
53
+ <ConfirmDialog.Cancel className={signOutCancelClass} />
54
+ <ConfirmDialog.Action
76
55
  onClick={() => void onConfirm()}
77
- disabled={pending}
56
+ pendingLabel="Signing out..."
57
+ spinnerSize={16}
78
58
  className="bg-danger text-danger-foreground"
79
59
  >
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>
60
+ {title}
61
+ </ConfirmDialog.Action>
62
+ </ConfirmDialog.Footer>
63
+ </ConfirmDialog.Content>
64
+ </ConfirmDialog.Root>
92
65
  );
93
66
  }
@@ -33,6 +33,9 @@ export function sidebarNavRowClass(isIconOnly: boolean): string {
33
33
  return cn(isIconOnly ? "justify-center px-0 py-1.5" : "gap-3 px-3 py-1.5");
34
34
  }
35
35
 
36
+ /** Sidebar header row: the panel title and its trailing actions. */
37
+ export const sidebarHeaderRowClass = "flex h-11 items-center gap-1 pl-3 pr-1";
38
+
36
39
  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
40
 
38
41
  export const panelCloseButtonClass = "rounded-lg text-muted hover:bg-surface-tertiary/50 hover:text-foreground"; // check-tokens-allow: text-muted
@@ -42,3 +45,6 @@ export const accountEmailClass = "block truncate text-xs font-normal text-muted"
42
45
  export const signOutDescriptionClass = "mt-1 text-sm text-muted"; // check-tokens-allow: text-muted
43
46
 
44
47
  export const signOutCancelClass = "text-muted"; // check-tokens-allow: text-muted
48
+
49
+ /** Label of the type-to-confirm field (`ConfirmDialog.Field`). */
50
+ export const confirmFieldLabelClass = "text-sm text-muted"; // check-tokens-allow: text-muted
@@ -11,9 +11,17 @@ export type {
11
11
  SessionsHoverCardSurface,
12
12
  } from "./SessionsHoverCard.js";
13
13
  export { AppShell } from "./AppShell.js";
14
- export type { AppShellProps } from "./AppShell.js";
14
+ export type {
15
+ AppShellContentProps,
16
+ AppShellMainProps,
17
+ AppShellProps,
18
+ AppShellRootProps,
19
+ AppShellSkipLinkProps,
20
+ } from "./AppShell.js";
15
21
  export { AppSidebar } from "./AppSidebar.js";
16
22
  export type { AppSidebarProps } from "./AppSidebar.js";
23
+ // Composable sidebar parts (`Sidebar.Root`, `Sidebar.Rail`, ...) and their prop types.
24
+ export * from "./Sidebar.js";
17
25
  export { SidebarRail, RailTile, RailDivider } from "./SidebarRail.js";
18
26
  export type { SidebarRailProps, RailTileProps, RailDividerProps } from "./SidebarRail.js";
19
27
  export { SidebarResizeHandle, useAppShellResize } from "./SidebarResize.js";
package/src/index.ts CHANGED
@@ -123,9 +123,17 @@ export type {
123
123
  // App shell: sidebar and main page layout. Widths come from the
124
124
  // `--app-shell-*` CSS variables that `AppShell` sets.
125
125
  export { AppShell } from "../shell/src/AppShell";
126
- export type { AppShellProps } from "../shell/src/AppShell";
126
+ export type {
127
+ AppShellContentProps,
128
+ AppShellMainProps,
129
+ AppShellProps,
130
+ AppShellRootProps,
131
+ AppShellSkipLinkProps,
132
+ } from "../shell/src/AppShell";
127
133
  export { AppSidebar } from "../shell/src/AppSidebar";
128
134
  export type { AppSidebarProps } from "../shell/src/AppSidebar";
135
+ // Composable sidebar parts (`Sidebar.Root`, `Sidebar.Rail`, ...) and their prop types.
136
+ export * from "../shell/src/Sidebar";
129
137
  export { SidebarRail, RailTile, RailDivider } from "../shell/src/SidebarRail";
130
138
  export type { SidebarRailProps, RailTileProps, RailDividerProps } from "../shell/src/SidebarRail";
131
139
  export { SidebarResizeHandle, useAppShellResize } from "../shell/src/SidebarResize";
@@ -0,0 +1,355 @@
1
+ import * as React from "react";
2
+ import { IconLoader2 } from "@tabler/icons-react";
3
+ import { confirmFieldLabelClass } from "../../shell/src/app-shell-classes";
4
+ import { cn } from "../utils/cn";
5
+ import {
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ } from "./alert-dialog";
10
+ import { Button, type ButtonProps } from "./button";
11
+ import {
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogFooter,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ } from "./dialog";
19
+ import { Input } from "./input";
20
+
21
+ export type ConfirmDialogSize = "default" | "sm" | "md";
22
+
23
+ type ConfirmDialogContextValue = {
24
+ size: ConfirmDialogSize;
25
+ alert: boolean;
26
+ pending: boolean;
27
+ focusCancel: boolean;
28
+ confirmPhrase: string | undefined;
29
+ typed: string;
30
+ setTyped: (value: string) => void;
31
+ phraseMatches: boolean;
32
+ /** Calls the Root's `onOpenChange(false)` unless `pending` (and not `dismissibleWhilePending`). */
33
+ requestClose: () => void;
34
+ };
35
+
36
+ const ConfirmDialogContext = React.createContext<ConfirmDialogContextValue>({
37
+ size: "default",
38
+ alert: true,
39
+ pending: false,
40
+ focusCancel: false,
41
+ confirmPhrase: undefined,
42
+ typed: "",
43
+ setTyped: () => {},
44
+ phraseMatches: true,
45
+ requestClose: () => {},
46
+ });
47
+
48
+ /** State of the nearest `ConfirmDialog.Root`, for custom parts. */
49
+ export function useConfirmDialog(): ConfirmDialogContextValue {
50
+ return React.useContext(ConfirmDialogContext);
51
+ }
52
+
53
+ export type ConfirmDialogRootProps = React.ComponentProps<typeof Dialog> & {
54
+ /** Layout slot for the other parts. Default `"default"`. */
55
+ size?: ConfirmDialogSize;
56
+ /** Blocking alert dialog: no outside dismiss, no close button, Cancel focused. Default `size === "default"`. */
57
+ alert?: boolean;
58
+ /**
59
+ * While `true`, close requests (Escape, outside click, close button, Cancel)
60
+ * are swallowed, Cancel is disabled and Action shows its pending state.
61
+ * Needs a controlled `open`.
62
+ */
63
+ pending?: boolean;
64
+ /**
65
+ * While `pending`, Escape, outside click and the close button still close
66
+ * (Root `onOpenChange(false)` runs). Cancel stays disabled and Action stays
67
+ * pending. Default `false`.
68
+ */
69
+ dismissibleWhilePending?: boolean;
70
+ /**
71
+ * Non-alert only: Cancel renders `data-alert-cancel=""`, so a Content with
72
+ * `role="alertdialog"` focuses it on open. An explicit `data-alert-cancel`
73
+ * on Cancel wins. No-op with `alert` (its Cancel already has it). Default `false`.
74
+ */
75
+ focusCancel?: boolean;
76
+ /** Type-to-confirm phrase for `ConfirmDialog.Field`. Action stays disabled until it matches (trimmed, case-insensitive). */
77
+ confirmPhrase?: string;
78
+ };
79
+
80
+ function Root({
81
+ size = "default",
82
+ alert = size === "default",
83
+ pending = false,
84
+ dismissibleWhilePending = false,
85
+ focusCancel = false,
86
+ confirmPhrase,
87
+ open,
88
+ onOpenChange,
89
+ children,
90
+ ...props
91
+ }: ConfirmDialogRootProps) {
92
+ const [typed, setTyped] = React.useState("");
93
+ const [prevOpen, setPrevOpen] = React.useState(open);
94
+ if (open !== prevOpen) {
95
+ setPrevOpen(open);
96
+ if (open) setTyped("");
97
+ }
98
+
99
+ const handleOpenChange = (next: boolean) => {
100
+ if (!next && pending && !dismissibleWhilePending) return;
101
+ if (next) setTyped("");
102
+ onOpenChange?.(next);
103
+ };
104
+
105
+ const phraseMatches =
106
+ confirmPhrase === undefined ||
107
+ typed.trim().toLowerCase() === confirmPhrase.trim().toLowerCase();
108
+
109
+ return (
110
+ <ConfirmDialogContext.Provider
111
+ value={{
112
+ size,
113
+ alert,
114
+ pending,
115
+ focusCancel,
116
+ confirmPhrase,
117
+ typed,
118
+ setTyped,
119
+ phraseMatches,
120
+ requestClose: () => handleOpenChange(false),
121
+ }}
122
+ >
123
+ <Dialog {...props} open={open} onOpenChange={handleOpenChange}>
124
+ {children}
125
+ </Dialog>
126
+ </ConfirmDialogContext.Provider>
127
+ );
128
+ }
129
+ Root.displayName = "ConfirmDialog.Root";
130
+
131
+ export type ConfirmDialogContentProps = React.ComponentProps<typeof DialogContent>;
132
+
133
+ /** Alert or plain dialog content (from Root `alert`); `sm` and `md` cap the width. */
134
+ function Content({ className, ...props }: ConfirmDialogContentProps) {
135
+ const { size, alert } = useConfirmDialog();
136
+ const Comp = alert ? AlertDialogContent : DialogContent;
137
+ return <Comp {...props} className={cn(size !== "default" && "sm:max-w-96", className)} />;
138
+ }
139
+ Content.displayName = "ConfirmDialog.Content";
140
+
141
+ export type ConfirmDialogHeaderProps = React.HTMLAttributes<HTMLDivElement>;
142
+
143
+ function Header({ className, ...props }: ConfirmDialogHeaderProps) {
144
+ const { size } = useConfirmDialog();
145
+ return <DialogHeader {...props} className={cn(size === "md" && "pb-4", className)} />;
146
+ }
147
+ Header.displayName = "ConfirmDialog.Header";
148
+
149
+ export type ConfirmDialogTitleProps = React.ComponentProps<typeof DialogTitle>;
150
+
151
+ function Title({ className, ...props }: ConfirmDialogTitleProps) {
152
+ const { size } = useConfirmDialog();
153
+ return <DialogTitle {...props} className={cn(size === "sm" && "mb-2 text-sm font-medium", className)} />;
154
+ }
155
+ Title.displayName = "ConfirmDialog.Title";
156
+
157
+ export type ConfirmDialogDescriptionProps = React.ComponentProps<typeof DialogDescription>;
158
+
159
+ function Description({ className, ...props }: ConfirmDialogDescriptionProps) {
160
+ const { size } = useConfirmDialog();
161
+ return (
162
+ <DialogDescription
163
+ {...props}
164
+ className={cn(
165
+ size === "sm" && "mb-4 text-sm text-muted-foreground",
166
+ size === "md" && "py-6 text-muted-foreground",
167
+ className,
168
+ )}
169
+ />
170
+ );
171
+ }
172
+ // Matched by `DialogContent` when it checks an alert dialog for a description.
173
+ Description.displayName = "ConfirmDialog.Description";
174
+
175
+ export type ConfirmDialogBodyProps = React.ComponentProps<"div">;
176
+
177
+ /** Plain `<div>` for extra content. No default classes. */
178
+ function Body(props: ConfirmDialogBodyProps) {
179
+ return <div {...props} />;
180
+ }
181
+ Body.displayName = "ConfirmDialog.Body";
182
+
183
+ export type ConfirmDialogFooterProps = React.HTMLAttributes<HTMLDivElement> & {
184
+ /**
185
+ * Right-aligned row of buttons at every width, laid out like a
186
+ * `flex justify-end gap-2` div inside the footer. Adds `justify-end flex-row!`:
187
+ * the `!important` beats both the footer's own `flex-col-reverse` below `sm`
188
+ * and the compact sheet's descendant `flex-col-reverse` rule, which has higher
189
+ * specificity than a plain class. Keeps `data-slot` and every other footer
190
+ * class. No-op for `size="sm"` (already a row). Default `false`.
191
+ */
192
+ row?: boolean;
193
+ };
194
+
195
+ function Footer({ row = false, className, ...props }: ConfirmDialogFooterProps) {
196
+ const { size } = useConfirmDialog();
197
+ if (size === "sm") return <div {...props} className={cn("flex justify-end gap-2", className)} />;
198
+ return (
199
+ <DialogFooter
200
+ {...props}
201
+ className={cn(size === "md" && "pt-4", row && "justify-end flex-row!", className)}
202
+ />
203
+ );
204
+ }
205
+ Footer.displayName = "ConfirmDialog.Footer";
206
+
207
+ export type ConfirmDialogCancelProps = ButtonProps;
208
+
209
+ /**
210
+ * Alert: a `DialogClose` outline button. Otherwise a ghost button that runs
211
+ * `onClick` and then, unless it called `preventDefault()`, the Root's
212
+ * `onOpenChange(false)`. Disabled while pending. With Root `focusCancel`
213
+ * it also gets `data-alert-cancel`.
214
+ */
215
+ function Cancel({ children = "Cancel", variant, size, disabled, onClick, ...props }: ConfirmDialogCancelProps) {
216
+ const dialog = useConfirmDialog();
217
+ if (dialog.alert) {
218
+ return (
219
+ <AlertDialogCancel
220
+ variant={variant}
221
+ size={size}
222
+ {...props}
223
+ onClick={onClick}
224
+ disabled={disabled || dialog.pending}
225
+ >
226
+ {children}
227
+ </AlertDialogCancel>
228
+ );
229
+ }
230
+ return (
231
+ <Button
232
+ variant={variant ?? "ghost"}
233
+ size={size ?? (dialog.size === "sm" ? "sm" : undefined)}
234
+ data-alert-cancel={dialog.focusCancel ? "" : undefined}
235
+ {...props}
236
+ onClick={(event) => {
237
+ onClick?.(event);
238
+ if (!event.defaultPrevented) dialog.requestClose();
239
+ }}
240
+ disabled={disabled || dialog.pending}
241
+ >
242
+ {children}
243
+ </Button>
244
+ );
245
+ }
246
+ Cancel.displayName = "ConfirmDialog.Cancel";
247
+
248
+ export type ConfirmDialogActionProps = ButtonProps & {
249
+ /** Default from Root `pending`. Disables the button. */
250
+ pending?: boolean;
251
+ /** Shown with a spinner while pending. Without it the children stay. */
252
+ pendingLabel?: React.ReactNode;
253
+ /** Spinner size in px. Default `14`. */
254
+ spinnerSize?: number;
255
+ /** Merged into the spinner className after `animate-spin`, e.g. `"mr-2"`. */
256
+ spinnerClassName?: string;
257
+ };
258
+
259
+ /** Confirm button. Does not close the dialog. Disabled while pending or until the confirm phrase matches. */
260
+ function Action({
261
+ pending: pendingProp,
262
+ pendingLabel,
263
+ spinnerSize = 14,
264
+ spinnerClassName,
265
+ size,
266
+ disabled,
267
+ children,
268
+ ...props
269
+ }: ConfirmDialogActionProps) {
270
+ const dialog = useConfirmDialog();
271
+ const pending = pendingProp ?? dialog.pending;
272
+ const content =
273
+ pending && pendingLabel !== undefined ? (
274
+ <>
275
+ <IconLoader2 size={spinnerSize} className={cn("animate-spin", spinnerClassName)} />
276
+ {pendingLabel}
277
+ </>
278
+ ) : (
279
+ children
280
+ );
281
+ const isDisabled = disabled || pending || !dialog.phraseMatches;
282
+ if (dialog.alert) {
283
+ return (
284
+ <AlertDialogAction size={size} {...props} disabled={isDisabled}>
285
+ {content}
286
+ </AlertDialogAction>
287
+ );
288
+ }
289
+ return (
290
+ <Button size={size ?? (dialog.size === "sm" ? "sm" : undefined)} {...props} disabled={isDisabled}>
291
+ {content}
292
+ </Button>
293
+ );
294
+ }
295
+ Action.displayName = "ConfirmDialog.Action";
296
+
297
+ export type ConfirmDialogFieldProps = {
298
+ /** Replaces the whole label content. Default "Type <phrase> to confirm". */
299
+ label?: React.ReactNode;
300
+ /** Enter in the input, only while the phrase matches and not pending. */
301
+ onEnter?: () => void;
302
+ className?: string;
303
+ /** Spread onto the `Input`; `id` also sets the label `htmlFor`. */
304
+ inputProps?: Omit<React.ComponentProps<typeof Input>, "value" | "defaultValue" | "onChange">;
305
+ };
306
+
307
+ /** Type-to-confirm input bound to the Root `confirmPhrase`. */
308
+ function Field({ label, onEnter, className, inputProps }: ConfirmDialogFieldProps) {
309
+ const { confirmPhrase = "", typed, setTyped, phraseMatches, pending } = useConfirmDialog();
310
+ const autoId = React.useId();
311
+ const { id = autoId, onKeyDown, ...rest } = inputProps ?? {};
312
+ return (
313
+ <div className={cn("space-y-2", className)}>
314
+ <label htmlFor={id} className={confirmFieldLabelClass}>
315
+ {label ?? (
316
+ <>
317
+ Type{" "}
318
+ <span className="font-mono text-foreground">{confirmPhrase}</span>{" "}
319
+ to confirm
320
+ </>
321
+ )}
322
+ </label>
323
+ <Input
324
+ id={id}
325
+ autoComplete="off"
326
+ autoFocus
327
+ value={typed}
328
+ onChange={(event) => setTyped(event.target.value)}
329
+ disabled={pending}
330
+ placeholder={confirmPhrase}
331
+ {...rest}
332
+ onKeyDown={(event) => {
333
+ onKeyDown?.(event);
334
+ if (event.key !== "Enter" || !phraseMatches || pending) return;
335
+ event.preventDefault();
336
+ onEnter?.();
337
+ }}
338
+ />
339
+ </div>
340
+ );
341
+ }
342
+ Field.displayName = "ConfirmDialog.Field";
343
+
344
+ export const ConfirmDialogParts = {
345
+ Root,
346
+ Content,
347
+ Header,
348
+ Title,
349
+ Description,
350
+ Body,
351
+ Footer,
352
+ Cancel,
353
+ Action,
354
+ Field,
355
+ };