@sentry/junior-dashboard 0.265.0 → 0.266.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.
Files changed (47) hide show
  1. package/dist/app.js +2 -2
  2. package/dist/client.js +48 -48
  3. package/dist/index.js +2 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +3 -3
  6. package/src/client/components/ActorAvatar.tsx +10 -22
  7. package/src/client/components/ConnectionBanner.tsx +4 -3
  8. package/src/client/components/CopyButton.tsx +3 -4
  9. package/src/client/components/EmptyTelemetry.tsx +5 -3
  10. package/src/client/components/ImageAttachment.tsx +49 -63
  11. package/src/client/components/InlineError.tsx +8 -4
  12. package/src/client/components/Notice.tsx +19 -26
  13. package/src/client/components/Pagination.tsx +26 -26
  14. package/src/client/components/ParticipantAvatarStack.tsx +10 -10
  15. package/src/client/components/README.md +17 -1
  16. package/src/client/components/SearchInput.tsx +51 -41
  17. package/src/client/components/VersionDriftBanner.tsx +9 -7
  18. package/src/client/components/charts/CacheWrites.tsx +52 -43
  19. package/src/client/components/ui/alert.tsx +64 -0
  20. package/src/client/components/ui/avatar.tsx +53 -0
  21. package/src/client/components/ui/button.tsx +2 -1
  22. package/src/client/components/ui/dialog.tsx +66 -0
  23. package/src/client/components/ui/empty.tsx +32 -0
  24. package/src/client/components/ui/input-group.tsx +96 -0
  25. package/src/client/components/ui/pagination.tsx +60 -0
  26. package/src/client/components/ui/spinner.tsx +21 -0
  27. package/src/client/components/ui/table.tsx +22 -1
  28. package/src/client/components/ui/tooltip.tsx +1 -1
  29. package/src/client/conversations/ConversationChildAgents.tsx +5 -5
  30. package/src/client/conversations/ConversationHeader.tsx +5 -4
  31. package/src/client/conversations/ConversationHeaderActions.tsx +7 -11
  32. package/src/client/conversations/ConversationSidebar.tsx +5 -4
  33. package/src/client/conversations/ConversationUsageBreakdown.tsx +108 -65
  34. package/src/client/conversations/ConversationWatches.tsx +67 -59
  35. package/src/client/conversations/DetailsList.tsx +44 -29
  36. package/src/client/conversations/EventUsage.tsx +99 -74
  37. package/src/client/conversations/PendingMailboxStack.tsx +5 -10
  38. package/src/client/conversations/README.md +3 -0
  39. package/src/client/conversations/ShareConversationButton.tsx +3 -4
  40. package/src/client/conversations/TranscriptEventView.tsx +35 -16
  41. package/src/client/conversations/TranscriptMessageContextView.tsx +31 -20
  42. package/src/client/conversations/TranscriptStructuredEventView.tsx +25 -15
  43. package/src/client/conversations/TranscriptSummary.tsx +34 -2
  44. package/src/client/conversations/TranscriptTurnContextView.tsx +45 -38
  45. package/src/client/conversations/eventLog.ts +1 -0
  46. package/src/client/conversations/transcriptMentions.tsx +18 -5
  47. package/src/mock-reporting/watches.ts +1 -1
@@ -2,6 +2,12 @@ import type { Ref } from "react";
2
2
  import { Search, X } from "lucide-react";
3
3
 
4
4
  import { cn } from "../styles";
5
+ import {
6
+ InputGroup,
7
+ InputGroupAddon,
8
+ InputGroupButton,
9
+ InputGroupInput,
10
+ } from "./ui/input-group";
5
11
 
6
12
  type SearchInputSize = "compact" | "default";
7
13
 
@@ -15,23 +21,14 @@ export function SearchInput(props: {
15
21
  size?: SearchInputSize;
16
22
  value: string;
17
23
  }) {
18
- const size = props.size ?? "compact";
24
+ const size = sizeClass[props.size ?? "compact"];
19
25
  return (
20
- <div className={cn("relative min-w-0", props.className)}>
21
- <Search
22
- aria-hidden="true"
23
- className={cn(
24
- "pointer-events-none absolute top-1/2 -translate-y-1/2 text-dashboard-text-muted",
25
- searchIconClass[size],
26
- )}
27
- size={size === "compact" ? 13 : 14}
28
- strokeWidth={2.5}
29
- />
30
- <input
26
+ <InputGroup className={cn(size.group, props.className)}>
27
+ <InputGroupInput
31
28
  aria-label={props.label}
32
29
  className={cn(
33
- "w-full rounded-md border border-dashboard-border font-sans text-sm text-dashboard-text outline-none transition-colors placeholder:text-dashboard-text-muted/70 hover:border-dashboard-border-emphasis focus:border-dashboard-focus [&::-webkit-search-cancel-button]:appearance-none",
34
- searchInputClass[size],
30
+ "[&::-webkit-search-cancel-button]:appearance-none",
31
+ size.input,
35
32
  )}
36
33
  onChange={(event) => props.onChange(event.currentTarget.value)}
37
34
  placeholder={props.placeholder}
@@ -39,40 +36,53 @@ export function SearchInput(props: {
39
36
  type="search"
40
37
  value={props.value}
41
38
  />
42
- {props.value ? (
43
- <button
39
+ <InputGroupAddon className={size.icon}>
40
+ <Search aria-hidden="true" size={size.iconSize} strokeWidth={2.5} />
41
+ </InputGroupAddon>
42
+ {/* The clear button keeps its space while hidden, so the field width does not change. */}
43
+ <InputGroupAddon align="inline-end" className={size.clear}>
44
+ <InputGroupButton
44
45
  aria-label="Clear search"
45
- className={cn(
46
- "absolute top-1/2 grid size-6 -translate-y-1/2 cursor-pointer place-items-center rounded-md text-dashboard-text-muted transition-colors hover:bg-dashboard-fill-strong hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus",
47
- clearButtonClass[size],
48
- )}
46
+ className={props.value ? undefined : "invisible"}
49
47
  onClick={(event) => {
50
48
  props.onChange("");
51
- // The button unmounts once the value is empty, so keep focus in the search box.
52
- event.currentTarget.parentElement?.querySelector("input")?.focus();
49
+ // The button hides once the value is empty, so keep focus in the search box.
50
+ event.currentTarget
51
+ .closest("[data-slot=input-group]")
52
+ ?.querySelector("input")
53
+ ?.focus();
53
54
  }}
54
- type="button"
55
55
  >
56
56
  <X aria-hidden="true" size={14} strokeWidth={2.5} />
57
- </button>
58
- ) : null}
59
- </div>
57
+ </InputGroupButton>
58
+ </InputGroupAddon>
59
+ </InputGroup>
60
60
  );
61
61
  }
62
62
 
63
- const searchIconClass: Record<SearchInputSize, string> = {
64
- compact: "left-2.5",
65
- default: "left-3",
66
- };
67
-
68
- const clearButtonClass: Record<SearchInputSize, string> = {
69
- compact: "right-1.5",
70
- default: "right-2",
71
- };
72
-
73
- const searchInputClass: Record<SearchInputSize, string> = {
74
- compact:
75
- "h-9 bg-dashboard-overlay-soft pr-8 pl-8 focus:ring-1 focus:ring-dashboard-focus/25",
76
- default:
77
- "h-10 bg-dashboard-overlay-soft pr-9 pl-9 focus:ring-1 focus:ring-dashboard-focus/25",
63
+ // Match the icon, text, and clear button offsets of the bordered input.
64
+ const sizeClass: Record<
65
+ SearchInputSize,
66
+ {
67
+ clear: string;
68
+ group: string;
69
+ icon: string;
70
+ iconSize: number;
71
+ input: string;
72
+ }
73
+ > = {
74
+ compact: {
75
+ clear: "pr-1.25",
76
+ group: "h-9",
77
+ icon: "pl-2.25",
78
+ iconSize: 13,
79
+ input: "pr-1 pl-2.5",
80
+ },
81
+ default: {
82
+ clear: "pr-1.75",
83
+ group: "h-10",
84
+ icon: "pl-2.75",
85
+ iconSize: 14,
86
+ input: "pr-1 pl-2.75",
87
+ },
78
88
  };
@@ -1,24 +1,26 @@
1
1
  import { JUNIOR_VERSION } from "@sentry/junior/version";
2
2
 
3
+ import { Alert } from "./ui/alert";
4
+ import { Button } from "./ui/button";
5
+
3
6
  /** Prompt the viewer to load the dashboard bundle that matches the server. */
4
7
  export function VersionDriftBanner(props: { serverVersion?: string }) {
5
8
  if (!dashboardVersionDrift(props.serverVersion)) return null;
6
9
 
7
10
  return (
8
- <div
11
+ <Alert
9
12
  aria-live="polite"
10
- className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t border-dashboard-accent/15 bg-dashboard-accent/[0.07] px-3 py-1.5 text-center font-sans text-xs text-dashboard-accent-text/80"
13
+ className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 rounded-none border-0 border-t border-dashboard-accent/15 bg-dashboard-accent/[0.07] px-3 py-1.5 text-center font-sans text-xs text-dashboard-accent-text/80"
11
14
  role="status"
12
15
  >
13
16
  <span>The dashboard changed. Refresh to load the latest version.</span>
14
- <button
15
- className="cursor-pointer border-0 bg-transparent p-0 font-sans font-semibold text-dashboard-accent-text underline decoration-dashboard-accent/40 underline-offset-2 hover:text-dashboard-text-solid"
17
+ <Button
18
+ className="h-auto border-0 bg-transparent p-0 text-xs font-semibold leading-normal text-dashboard-accent-text underline decoration-dashboard-accent/40 underline-offset-2 hover:bg-transparent hover:text-dashboard-text-solid"
16
19
  onClick={() => window.location.reload()}
17
- type="button"
18
20
  >
19
21
  Refresh
20
- </button>
21
- </div>
22
+ </Button>
23
+ </Alert>
22
24
  );
23
25
  }
24
26
 
@@ -9,6 +9,14 @@ import {
9
9
  CollapsibleContent,
10
10
  CollapsibleTrigger,
11
11
  } from "../ui/collapsible";
12
+ import {
13
+ Table,
14
+ TableBody,
15
+ TableCell,
16
+ TableHead,
17
+ TableHeader,
18
+ TableRow,
19
+ } from "../ui/table";
12
20
  import { dashboardDisclosureChevronClass } from "../../styles";
13
21
 
14
22
  /** Identify the largest cache writes within the page's selected time window. */
@@ -40,50 +48,51 @@ export function CacheWrites(props: {
40
48
  Top three by tokens written per {unit}, within the selected time
41
49
  window. Large writes alone do not mean a cache problem.
42
50
  </p>
43
- <div className="overflow-x-auto">
44
- <table
45
- aria-label="Largest cache writes"
46
- className="w-full min-w-80 table-fixed border-collapse font-sans text-xs tabular-nums"
47
- >
48
- <thead className="text-dashboard-text-muted">
49
- <tr>
50
- <th scope="col" className="py-2 text-left font-normal">
51
- {props.bucketUnit === "day" ? "Day" : "Starting at"}
52
- </th>
53
- <th scope="col" className="w-24 py-2 text-right font-normal">
54
- Written
55
- </th>
56
- <th scope="col" className="w-24 py-2 text-right font-normal">
57
- From cache
58
- </th>
59
- </tr>
60
- </thead>
61
- <tbody>
62
- {ranked.map((day) => (
63
- <tr
64
- key={day.date}
65
- className="border-t border-dashboard-border-subtle"
51
+ <Table
52
+ aria-label="Largest cache writes"
53
+ className="min-w-80 table-fixed font-sans text-xs tabular-nums"
54
+ >
55
+ <TableHeader>
56
+ <TableRow>
57
+ <TableHead scope="col" className="px-0 py-2 font-normal">
58
+ {props.bucketUnit === "day" ? "Day" : "Starting at"}
59
+ </TableHead>
60
+ <TableHead
61
+ scope="col"
62
+ className="w-24 px-0 py-2 text-right font-normal"
63
+ >
64
+ Written
65
+ </TableHead>
66
+ <TableHead
67
+ scope="col"
68
+ className="w-24 px-0 py-2 text-right font-normal"
69
+ >
70
+ From cache
71
+ </TableHead>
72
+ </TableRow>
73
+ </TableHeader>
74
+ <TableBody>
75
+ {ranked.map((day) => (
76
+ <TableRow key={day.date}>
77
+ <TableHead
78
+ scope="row"
79
+ className="px-0 py-2 font-normal whitespace-normal"
66
80
  >
67
- <th
68
- scope="row"
69
- className="py-2 text-left font-normal text-dashboard-text-muted"
70
- >
71
- {formatActivityTooltipDate(day.date)}
72
- </th>
73
- <td className="py-2 text-right text-dashboard-text">
74
- {formatCompactNumber(day.cacheCreationTokens)}
75
- </td>
76
- <td className="py-2 text-right text-dashboard-text">
77
- {formatCacheShare(
78
- day.cachedInputTokens,
79
- cacheInputTotal(day),
80
- )}
81
- </td>
82
- </tr>
83
- ))}
84
- </tbody>
85
- </table>
86
- </div>
81
+ {formatActivityTooltipDate(day.date)}
82
+ </TableHead>
83
+ <TableCell className="px-0 py-2 text-right whitespace-normal">
84
+ {formatCompactNumber(day.cacheCreationTokens)}
85
+ </TableCell>
86
+ <TableCell className="px-0 py-2 text-right whitespace-normal">
87
+ {formatCacheShare(
88
+ day.cachedInputTokens,
89
+ cacheInputTotal(day),
90
+ )}
91
+ </TableCell>
92
+ </TableRow>
93
+ ))}
94
+ </TableBody>
95
+ </Table>
87
96
  </CollapsibleContent>
88
97
  </Collapsible>
89
98
  );
@@ -0,0 +1,64 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "@dashboard/lib/utils";
4
+
5
+ const alertVariants = cva(
6
+ "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default:
11
+ "border-dashboard-border-strong bg-dashboard-surface-raised text-dashboard-text",
12
+ destructive:
13
+ "border-rose-300/25 bg-dashboard-surface-raised text-rose-50 *:data-[slot=alert-description]:text-rose-100/70 *:[svg]:text-current",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ },
19
+ },
20
+ );
21
+
22
+ /** Show a message in a live region. Pass `role="status"` for polite updates. */
23
+ function Alert({
24
+ className,
25
+ variant,
26
+ ...props
27
+ }: ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
28
+ return (
29
+ <div
30
+ data-slot="alert"
31
+ role="alert"
32
+ className={cn(alertVariants({ variant }), className)}
33
+ {...props}
34
+ />
35
+ );
36
+ }
37
+
38
+ function AlertTitle({ className, ...props }: ComponentProps<"div">) {
39
+ return (
40
+ <div
41
+ data-slot="alert-title"
42
+ className={cn(
43
+ "font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-dashboard-text",
44
+ className,
45
+ )}
46
+ {...props}
47
+ />
48
+ );
49
+ }
50
+
51
+ function AlertDescription({ className, ...props }: ComponentProps<"div">) {
52
+ return (
53
+ <div
54
+ data-slot="alert-description"
55
+ className={cn(
56
+ "text-sm text-balance text-dashboard-text-muted md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-dashboard-text [&_p:not(:last-child)]:mb-4",
57
+ className,
58
+ )}
59
+ {...props}
60
+ />
61
+ );
62
+ }
63
+
64
+ export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,53 @@
1
+ import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
2
+ import { cn } from "@dashboard/lib/utils";
3
+
4
+ /** Render a round avatar frame. Use `size="sm"` in dense lists. */
5
+ function Avatar({
6
+ className,
7
+ size = "default",
8
+ ...props
9
+ }: AvatarPrimitive.Root.Props & {
10
+ size?: "default" | "sm";
11
+ }) {
12
+ return (
13
+ <AvatarPrimitive.Root
14
+ data-slot="avatar"
15
+ data-size={size}
16
+ className={cn(
17
+ "group/avatar relative inline-grid size-7 shrink-0 place-items-center overflow-hidden rounded-full border-2 border-dashboard-bg bg-dashboard-focus shadow-sm select-none data-[size=sm]:size-6",
18
+ className,
19
+ )}
20
+ {...props}
21
+ />
22
+ );
23
+ }
24
+
25
+ /** Show the avatar image after it loads. */
26
+ function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
27
+ return (
28
+ <AvatarPrimitive.Image
29
+ data-slot="avatar-image"
30
+ className={cn("size-full object-cover", className)}
31
+ {...props}
32
+ />
33
+ );
34
+ }
35
+
36
+ /** Show initials or a count while the image is missing, loading, or broken. */
37
+ function AvatarFallback({
38
+ className,
39
+ ...props
40
+ }: AvatarPrimitive.Fallback.Props) {
41
+ return (
42
+ <AvatarPrimitive.Fallback
43
+ data-slot="avatar-fallback"
44
+ className={cn(
45
+ "grid size-full place-items-center font-sans text-xs leading-none font-semibold tracking-tight text-dashboard-text-inverse group-data-[size=sm]/avatar:text-2xs",
46
+ className,
47
+ )}
48
+ {...props}
49
+ />
50
+ );
51
+ }
52
+
53
+ export { Avatar, AvatarImage, AvatarFallback };
@@ -5,7 +5,7 @@ import { cn } from "@dashboard/lib/utils";
5
5
  const commandButtonClass =
6
6
  "inline-flex h-9 max-w-full items-center justify-center gap-2 rounded-md border px-3 text-sm font-medium leading-none focus-visible:outline-2 focus-visible:outline-offset-2";
7
7
 
8
- /** Style the dashboard command, primary, danger, and icon button surfaces. Links use it with `className`. */
8
+ /** Style the dashboard command, primary, danger, icon, and menu row button surfaces. Links use it with `className`. */
9
9
  const buttonVariants = cva(
10
10
  "cursor-pointer font-sans no-underline transition-colors data-disabled:cursor-not-allowed data-disabled:opacity-50",
11
11
  {
@@ -18,6 +18,7 @@ const buttonVariants = cva(
18
18
  size: {
19
19
  default: "",
20
20
  icon: "grid size-9 place-items-center rounded-md border-0 bg-transparent p-0 text-dashboard-text-muted focus-visible:outline-2 focus-visible:outline-offset-1 data-disabled:hover:!bg-transparent data-disabled:hover:!text-dashboard-text-muted",
21
+ menu: "flex w-full items-center gap-2.5 rounded-md border-0 bg-transparent px-2.5 py-2 text-left text-sm font-semibold text-dashboard-text hover:bg-dashboard-fill-strong focus-visible:bg-dashboard-fill-strong focus-visible:outline-none data-disabled:hover:bg-transparent",
21
22
  },
22
23
  },
23
24
  compoundVariants: [
@@ -0,0 +1,66 @@
1
+ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
2
+ import { cn } from "@dashboard/lib/utils";
3
+
4
+ /** Own the open state and modal focus rules for a dialog. */
5
+ function Dialog({ ...props }: DialogPrimitive.Root.Props) {
6
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />;
7
+ }
8
+
9
+ function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
10
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
11
+ }
12
+
13
+ /** Close the dialog through Base UI dismissal and focus handling. */
14
+ function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
15
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
16
+ }
17
+
18
+ function DialogOverlay({
19
+ className,
20
+ ...props
21
+ }: DialogPrimitive.Backdrop.Props) {
22
+ return (
23
+ <DialogPrimitive.Backdrop
24
+ data-slot="dialog-overlay"
25
+ className={cn(
26
+ "fixed inset-0 isolate z-50 bg-dashboard-overlay transition-opacity duration-180 motion-reduce:transition-none data-ending-style:opacity-0 data-starting-style:opacity-0",
27
+ className,
28
+ )}
29
+ {...props}
30
+ />
31
+ );
32
+ }
33
+
34
+ /** Show the dialog above a backdrop, centered in the viewport. */
35
+ function DialogContent({ className, ...props }: DialogPrimitive.Popup.Props) {
36
+ return (
37
+ <DialogPortal>
38
+ <DialogOverlay />
39
+ <DialogPrimitive.Popup
40
+ data-slot="dialog-content"
41
+ data-dashboard-modal=""
42
+ className={cn(
43
+ "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-dashboard-border-emphasis bg-dashboard-surface-raised p-4 text-sm text-dashboard-text shadow-lg outline-none transition-[opacity,scale] duration-180 motion-reduce:transition-none sm:max-w-sm data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:scale-95 data-starting-style:scale-95",
44
+ className,
45
+ )}
46
+ {...props}
47
+ />
48
+ </DialogPortal>
49
+ );
50
+ }
51
+
52
+ /** Give the dialog its accessible name. */
53
+ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
54
+ return (
55
+ <DialogPrimitive.Title
56
+ data-slot="dialog-title"
57
+ className={cn(
58
+ "m-0 text-base leading-none font-medium text-dashboard-text",
59
+ className,
60
+ )}
61
+ {...props}
62
+ />
63
+ );
64
+ }
65
+
66
+ export { Dialog, DialogClose, DialogContent, DialogTitle };
@@ -0,0 +1,32 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cn } from "@dashboard/lib/utils";
3
+
4
+ /** Frame an empty state inside a page or list region. */
5
+ function Empty({ className, ...props }: ComponentProps<"div">) {
6
+ return (
7
+ <div
8
+ data-slot="empty"
9
+ className={cn(
10
+ "min-w-0 rounded-lg border border-dashboard-border-strong bg-dashboard-bg px-4 py-3",
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ /** Explain why the region is empty and what the user can do next. */
19
+ function EmptyDescription({ className, ...props }: ComponentProps<"div">) {
20
+ return (
21
+ <div
22
+ data-slot="empty-description"
23
+ className={cn(
24
+ "text-base leading-relaxed text-dashboard-text-muted",
25
+ className,
26
+ )}
27
+ {...props}
28
+ />
29
+ );
30
+ }
31
+
32
+ export { Empty, EmptyDescription };
@@ -0,0 +1,96 @@
1
+ import type { ComponentProps } from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "@dashboard/lib/utils";
4
+
5
+ import { Button } from "@dashboard/components/ui/button";
6
+ import { Input } from "@dashboard/components/ui/input";
7
+
8
+ /** Frame one text input with its icons and inline buttons. */
9
+ function InputGroup({ className, ...props }: ComponentProps<"div">) {
10
+ return (
11
+ <div
12
+ data-slot="input-group"
13
+ role="group"
14
+ className={cn(
15
+ "group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-dashboard-border bg-dashboard-overlay-soft transition-colors outline-none hover:border-dashboard-border-emphasis has-[[data-slot=input-group-control]:focus-visible]:border-dashboard-focus has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-dashboard-focus/25",
16
+ className,
17
+ )}
18
+ {...props}
19
+ />
20
+ );
21
+ }
22
+
23
+ const inputGroupAddonVariants = cva(
24
+ "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-dashboard-text-muted select-none",
25
+ {
26
+ variants: {
27
+ align: {
28
+ "inline-start": "order-first pl-2.5",
29
+ "inline-end": "order-last pr-1.5",
30
+ },
31
+ },
32
+ defaultVariants: {
33
+ align: "inline-start",
34
+ },
35
+ },
36
+ );
37
+
38
+ /** Place an icon or button before or after the input. A click on the addon focuses the input. */
39
+ function InputGroupAddon({
40
+ className,
41
+ align = "inline-start",
42
+ ...props
43
+ }: ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
44
+ return (
45
+ <div
46
+ role="group"
47
+ data-slot="input-group-addon"
48
+ data-align={align}
49
+ className={cn(inputGroupAddonVariants({ align }), className)}
50
+ onClick={(e) => {
51
+ if ((e.target as HTMLElement).closest("button")) {
52
+ return;
53
+ }
54
+ e.currentTarget.parentElement?.querySelector("input")?.focus();
55
+ }}
56
+ {...props}
57
+ />
58
+ );
59
+ }
60
+
61
+ /** Render a compact icon button inside an input group. */
62
+ function InputGroupButton({
63
+ className,
64
+ type = "button",
65
+ ...props
66
+ }: Omit<ComponentProps<typeof Button>, "size" | "type"> & {
67
+ type?: "button" | "submit" | "reset";
68
+ }) {
69
+ return (
70
+ <Button
71
+ type={type}
72
+ size="icon"
73
+ className={cn("size-6", className)}
74
+ {...props}
75
+ />
76
+ );
77
+ }
78
+
79
+ /** Render the text input inside an input group. */
80
+ function InputGroupInput({
81
+ className,
82
+ ...props
83
+ }: ComponentProps<typeof Input>) {
84
+ return (
85
+ <Input
86
+ data-slot="input-group-control"
87
+ className={cn(
88
+ "h-full min-w-0 flex-1 rounded-none border-0 bg-transparent px-2 focus:ring-0",
89
+ className,
90
+ )}
91
+ {...props}
92
+ />
93
+ );
94
+ }
95
+
96
+ export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput };
@@ -0,0 +1,60 @@
1
+ import type { ComponentProps } from "react";
2
+ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
3
+ import { cn } from "@dashboard/lib/utils";
4
+
5
+ import { Button } from "@dashboard/components/ui/button";
6
+
7
+ /** Mark a page control group as navigation. */
8
+ function Pagination({ className, ...props }: ComponentProps<"nav">) {
9
+ return (
10
+ <nav
11
+ role="navigation"
12
+ aria-label="Pagination"
13
+ data-slot="pagination"
14
+ className={cn("flex w-full justify-center", className)}
15
+ {...props}
16
+ />
17
+ );
18
+ }
19
+
20
+ /** Hold the page controls as a list. */
21
+ function PaginationContent({ className, ...props }: ComponentProps<"ul">) {
22
+ return (
23
+ <ul
24
+ data-slot="pagination-content"
25
+ className={cn("flex items-center gap-2", className)}
26
+ {...props}
27
+ />
28
+ );
29
+ }
30
+
31
+ /** Hold one page control. */
32
+ function PaginationItem({ ...props }: ComponentProps<"li">) {
33
+ return <li data-slot="pagination-item" {...props} />;
34
+ }
35
+
36
+ /** Go to the previous page. */
37
+ function PaginationPrevious(props: ComponentProps<typeof Button>) {
38
+ return (
39
+ <Button aria-label="Previous page" size="icon" {...props}>
40
+ <ChevronLeftIcon aria-hidden="true" size={16} />
41
+ </Button>
42
+ );
43
+ }
44
+
45
+ /** Go to the next page. */
46
+ function PaginationNext(props: ComponentProps<typeof Button>) {
47
+ return (
48
+ <Button aria-label="Next page" size="icon" {...props}>
49
+ <ChevronRightIcon aria-hidden="true" size={16} />
50
+ </Button>
51
+ );
52
+ }
53
+
54
+ export {
55
+ Pagination,
56
+ PaginationContent,
57
+ PaginationItem,
58
+ PaginationNext,
59
+ PaginationPrevious,
60
+ };