@withmata/blueprints 0.3.5 → 0.4.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 (62) hide show
  1. package/.claude/skills/audit/SKILL.md +4 -4
  2. package/.claude/skills/blueprint-catalog/SKILL.md +17 -7
  3. package/.claude/skills/copywrite/SKILL.md +187 -0
  4. package/.claude/skills/copywrite-landing/SKILL.md +489 -0
  5. package/.claude/skills/design-system/SKILL.md +970 -0
  6. package/.claude/skills/new-project/SKILL.md +168 -112
  7. package/.claude/skills/scaffold-auth/SKILL.md +9 -9
  8. package/.claude/skills/scaffold-db/SKILL.md +14 -14
  9. package/.claude/skills/scaffold-env/SKILL.md +4 -4
  10. package/.claude/skills/scaffold-foundation/SKILL.md +15 -15
  11. package/.claude/skills/scaffold-tailwind/SKILL.md +17 -3
  12. package/.claude/skills/scaffold-ui/SKILL.md +155 -36
  13. package/ENGINEERING.md +2 -2
  14. package/blueprints/discovery/design-system/BLUEPRINT.md +1479 -0
  15. package/blueprints/discovery/marketing-copywriting/BLUEPRINT.md +664 -0
  16. package/blueprints/features/auth-better-auth/BLUEPRINT.md +20 -22
  17. package/blueprints/features/db-drizzle-postgres/BLUEPRINT.md +12 -12
  18. package/blueprints/features/db-drizzle-postgres/files/db/src/example-entity.ts +1 -1
  19. package/blueprints/features/db-drizzle-postgres/files/db/src/scripts/seed.ts +1 -1
  20. package/blueprints/features/env-t3/BLUEPRINT.md +1 -1
  21. package/blueprints/features/tailwind-v4/BLUEPRINT.md +9 -2
  22. package/blueprints/features/tailwind-v4/files/tailwind-config/shared-styles.css +80 -1
  23. package/blueprints/features/ui-shared-components/BLUEPRINT.md +411 -78
  24. package/blueprints/features/ui-shared-components/files/ui/components/ui/alert-dialog.tsx +192 -0
  25. package/blueprints/features/ui-shared-components/files/ui/components/ui/avatar.tsx +71 -0
  26. package/blueprints/features/ui-shared-components/files/ui/components/ui/badge.tsx +52 -0
  27. package/blueprints/features/ui-shared-components/files/ui/components/ui/breadcrumb.tsx +122 -0
  28. package/blueprints/features/ui-shared-components/files/ui/components/ui/button.tsx +56 -0
  29. package/blueprints/features/ui-shared-components/files/ui/components/ui/card-select.tsx +72 -0
  30. package/blueprints/features/ui-shared-components/files/ui/components/ui/card.tsx +100 -0
  31. package/blueprints/features/ui-shared-components/files/ui/components/ui/collapsible.tsx +34 -0
  32. package/blueprints/features/ui-shared-components/files/ui/components/ui/combobox.tsx +301 -0
  33. package/blueprints/features/ui-shared-components/files/ui/components/ui/dropdown-menu.tsx +264 -0
  34. package/blueprints/features/ui-shared-components/files/ui/components/ui/empty-state.tsx +43 -0
  35. package/blueprints/features/ui-shared-components/files/ui/components/ui/entity-select.tsx +110 -0
  36. package/blueprints/features/ui-shared-components/files/ui/components/ui/field.tsx +237 -0
  37. package/blueprints/features/ui-shared-components/files/ui/components/ui/form-field.tsx +217 -0
  38. package/blueprints/features/ui-shared-components/files/ui/components/ui/input-group.tsx +161 -0
  39. package/blueprints/features/ui-shared-components/files/ui/components/ui/input.tsx +20 -0
  40. package/blueprints/features/ui-shared-components/files/ui/components/ui/label.tsx +20 -0
  41. package/blueprints/features/ui-shared-components/files/ui/components/ui/org-switcher.tsx +114 -0
  42. package/blueprints/features/ui-shared-components/files/ui/components/ui/page-header.tsx +45 -0
  43. package/blueprints/features/ui-shared-components/files/ui/components/ui/pagination.tsx +52 -0
  44. package/blueprints/features/ui-shared-components/files/ui/components/ui/pill-select.tsx +151 -0
  45. package/blueprints/features/ui-shared-components/files/ui/components/ui/popover.tsx +41 -0
  46. package/blueprints/features/ui-shared-components/files/ui/components/ui/search-input.tsx +49 -0
  47. package/blueprints/features/ui-shared-components/files/ui/components/ui/select.tsx +205 -0
  48. package/blueprints/features/ui-shared-components/files/ui/components/ui/selected-entity-card.tsx +47 -0
  49. package/blueprints/features/ui-shared-components/files/ui/components/ui/separator.tsx +25 -0
  50. package/blueprints/features/ui-shared-components/files/ui/components/ui/sidebar.tsx +389 -0
  51. package/blueprints/features/ui-shared-components/files/ui/components/ui/status-filter.tsx +43 -0
  52. package/blueprints/features/ui-shared-components/files/ui/components/ui/tag-input.tsx +131 -0
  53. package/blueprints/features/ui-shared-components/files/ui/components/ui/textarea.tsx +18 -0
  54. package/blueprints/features/ui-shared-components/files/ui/components/ui/user-menu.tsx +149 -0
  55. package/blueprints/features/ui-shared-components/files/ui/components.json +11 -8
  56. package/blueprints/features/ui-shared-components/files/ui/package.json +20 -11
  57. package/blueprints/foundation/monorepo-turbo/BLUEPRINT.md +19 -20
  58. package/blueprints/foundation/monorepo-turbo/files/root/package.json +1 -1
  59. package/dist/index.js +1 -1
  60. package/package.json +1 -1
  61. package/blueprints/features/tailwind-v4/files/tailwind-config/package.json +0 -20
  62. package/blueprints/foundation/monorepo-turbo/files/root/pnpm-workspace.yaml +0 -5
@@ -0,0 +1,161 @@
1
+ "use client";
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import type * as React from "react";
5
+ import { Button } from "#components/ui/button";
6
+ import { Input } from "#components/ui/input";
7
+ import { Textarea } from "#components/ui/textarea";
8
+ import { cn } from "#utils/cn";
9
+
10
+ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
11
+ return (
12
+ <div
13
+ data-slot="input-group"
14
+ role="group"
15
+ className={cn(
16
+ "border-input bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 h-9 rounded-4xl border transition-colors has-data-[align=block-end]:rounded-2xl has-data-[align=block-start]:rounded-2xl has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[textarea]:rounded-xl has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
17
+ className,
18
+ )}
19
+ {...props}
20
+ />
21
+ );
22
+ }
23
+
24
+ const inputGroupAddonVariants = cva(
25
+ "text-muted-foreground **:data-[slot=kbd]:bg-muted-foreground/10 h-auto gap-2 py-2 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-4xl **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
26
+ {
27
+ variants: {
28
+ align: {
29
+ "inline-start":
30
+ "pl-3 has-[>button]:ml-[-0.25rem] has-[>kbd]:ml-[-0.15rem] order-first",
31
+ "inline-end":
32
+ "pr-3 has-[>button]:mr-[-0.25rem] has-[>kbd]:mr-[-0.15rem] order-last",
33
+ "block-start":
34
+ "px-3 pt-3 group-has-[>input]/input-group:pt-3 [.border-b]:pb-3 order-first w-full justify-start",
35
+ "block-end":
36
+ "px-3 pb-3 group-has-[>input]/input-group:pb-3 [.border-t]:pt-3 order-last w-full justify-start",
37
+ },
38
+ },
39
+ defaultVariants: {
40
+ align: "inline-start",
41
+ },
42
+ },
43
+ );
44
+
45
+ function InputGroupAddon({
46
+ className,
47
+ align = "inline-start",
48
+ ...props
49
+ }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
50
+ return (
51
+ <div
52
+ role="group"
53
+ data-slot="input-group-addon"
54
+ data-align={align}
55
+ className={cn(inputGroupAddonVariants({ align }), className)}
56
+ onKeyDown={(e) => {
57
+ if (e.key === "Enter" || e.key === " ") {
58
+ e.currentTarget.parentElement?.querySelector("input")?.focus();
59
+ }
60
+ }}
61
+ onClick={(e) => {
62
+ if ((e.target as HTMLElement).closest("button")) {
63
+ return;
64
+ }
65
+ e.currentTarget.parentElement?.querySelector("input")?.focus();
66
+ }}
67
+ {...props}
68
+ />
69
+ );
70
+ }
71
+
72
+ const inputGroupButtonVariants = cva(
73
+ "gap-2 rounded-4xl text-sm shadow-none flex items-center",
74
+ {
75
+ variants: {
76
+ size: {
77
+ xs: "h-6 gap-1 px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
78
+ sm: "",
79
+ "icon-xs": "size-6 p-0 has-[>svg]:p-0",
80
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
81
+ },
82
+ },
83
+ defaultVariants: {
84
+ size: "xs",
85
+ },
86
+ },
87
+ );
88
+
89
+ function InputGroupButton({
90
+ className,
91
+ type = "button",
92
+ variant = "ghost",
93
+ size = "xs",
94
+ ...props
95
+ }: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
96
+ VariantProps<typeof inputGroupButtonVariants> & {
97
+ type?: "button" | "submit" | "reset";
98
+ }) {
99
+ return (
100
+ <Button
101
+ type={type}
102
+ data-size={size}
103
+ variant={variant}
104
+ className={cn(inputGroupButtonVariants({ size }), className)}
105
+ {...props}
106
+ />
107
+ );
108
+ }
109
+
110
+ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
111
+ return (
112
+ <span
113
+ className={cn(
114
+ "text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
115
+ className,
116
+ )}
117
+ {...props}
118
+ />
119
+ );
120
+ }
121
+
122
+ function InputGroupInput({
123
+ className,
124
+ ...props
125
+ }: React.ComponentProps<"input">) {
126
+ return (
127
+ <Input
128
+ data-slot="input-group-control"
129
+ className={cn(
130
+ "rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1",
131
+ className,
132
+ )}
133
+ {...props}
134
+ />
135
+ );
136
+ }
137
+
138
+ function InputGroupTextarea({
139
+ className,
140
+ ...props
141
+ }: React.ComponentProps<"textarea">) {
142
+ return (
143
+ <Textarea
144
+ data-slot="input-group-control"
145
+ className={cn(
146
+ "rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1 resize-none",
147
+ className,
148
+ )}
149
+ {...props}
150
+ />
151
+ );
152
+ }
153
+
154
+ export {
155
+ InputGroup,
156
+ InputGroupAddon,
157
+ InputGroupButton,
158
+ InputGroupText,
159
+ InputGroupInput,
160
+ InputGroupTextarea,
161
+ };
@@ -0,0 +1,20 @@
1
+ import { Input as InputPrimitive } from "@base-ui/react/input";
2
+ import type * as React from "react";
3
+
4
+ import { cn } from "#utils/cn";
5
+
6
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
7
+ return (
8
+ <InputPrimitive
9
+ type={type}
10
+ data-slot="input"
11
+ className={cn(
12
+ "bg-input/30 border-input focus-visible:border-primary/90 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-4xl border px-3 py-1 text-base transition-colors file:h-7 file:text-sm file:font-medium md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
13
+ className,
14
+ )}
15
+ {...props}
16
+ />
17
+ );
18
+ }
19
+
20
+ export { Input };
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import type * as React from "react";
4
+
5
+ import { cn } from "#utils/cn";
6
+
7
+ function Label({ className, ...props }: React.ComponentProps<"label">) {
8
+ return (
9
+ <label
10
+ data-slot="label"
11
+ className={cn(
12
+ "gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
13
+ className,
14
+ )}
15
+ {...props}
16
+ />
17
+ );
18
+ }
19
+
20
+ export { Label };
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import { buttonVariants } from "#components/ui/button";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuGroup,
8
+ DropdownMenuItem,
9
+ DropdownMenuLabel,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuTrigger,
12
+ } from "#components/ui/dropdown-menu";
13
+ import { cn } from "#utils/cn";
14
+ import { BuildingsIcon } from "@phosphor-icons/react/Buildings";
15
+ import { CaretUpDownIcon } from "@phosphor-icons/react/CaretUpDown";
16
+ import { CheckIcon } from "@phosphor-icons/react/Check";
17
+ import { PlusIcon } from "@phosphor-icons/react/Plus";
18
+
19
+ export interface OrgSwitcherOrganization {
20
+ id: string;
21
+ name: string;
22
+ }
23
+
24
+ export interface OrgSwitcherProps {
25
+ currentOrg: OrgSwitcherOrganization | null;
26
+ organizations: OrgSwitcherOrganization[];
27
+ onSwitch: (orgId: string) => void;
28
+ onCreateNew?: () => void;
29
+ isLoading?: boolean;
30
+ isSwitching?: boolean;
31
+ createLabel?: string;
32
+ label?: string;
33
+ className?: string;
34
+ }
35
+
36
+ export function OrgSwitcher({
37
+ currentOrg,
38
+ organizations,
39
+ onSwitch,
40
+ onCreateNew,
41
+ isLoading = false,
42
+ isSwitching = false,
43
+ createLabel = "Create Organization",
44
+ label = "Organizations",
45
+ className,
46
+ }: OrgSwitcherProps) {
47
+ return (
48
+ <DropdownMenu>
49
+ <DropdownMenuTrigger
50
+ nativeButton={false}
51
+ render={
52
+ <div
53
+ data-slot="org-switcher"
54
+ className={cn(
55
+ buttonVariants({
56
+ variant: "ghost",
57
+ className:
58
+ "bg-transparent rounded-none group hover:cursor-pointer hover:bg-transparent focus:bg-transparent p-0",
59
+ }),
60
+ "relative flex gap-2.5 items-center size-8 w-full",
61
+ className,
62
+ )}
63
+ >
64
+ <BuildingsIcon className="size-4 shrink-0" />
65
+ <div className="flex gap-2 w-full items-center overflow-hidden">
66
+ <span className="max-w-32 truncate text-sm">
67
+ {currentOrg?.name ?? "Organization"}
68
+ </span>
69
+ </div>
70
+ <CaretUpDownIcon className="size-3.5 group-hover:text-foreground transition-all opacity-50 shrink-0" />
71
+ {(isSwitching || isLoading) && (
72
+ <div className="absolute inset-0 rounded-none overflow-hidden">
73
+ <div
74
+ className="h-full w-full animate-pulse bg-muted/60"
75
+ style={{ animationDuration: "1s" }}
76
+ />
77
+ </div>
78
+ )}
79
+ </div>
80
+ }
81
+ />
82
+ <DropdownMenuContent align="start" className="w-64">
83
+ <DropdownMenuGroup>
84
+ <DropdownMenuLabel>{label}</DropdownMenuLabel>
85
+ {organizations.map((org) => {
86
+ const isActive = org.id === currentOrg?.id;
87
+ return (
88
+ <DropdownMenuItem
89
+ key={org.id}
90
+ onClick={() => onSwitch(org.id)}
91
+ className="justify-between"
92
+ >
93
+ <div className="flex items-center gap-2">
94
+ <BuildingsIcon className="size-4 text-muted-foreground" />
95
+ <span className="truncate">{org.name}</span>
96
+ </div>
97
+ {isActive && <CheckIcon className="size-4" />}
98
+ </DropdownMenuItem>
99
+ );
100
+ })}
101
+ </DropdownMenuGroup>
102
+ {onCreateNew && (
103
+ <>
104
+ <DropdownMenuSeparator />
105
+ <DropdownMenuItem onClick={onCreateNew}>
106
+ <PlusIcon className="size-4" />
107
+ <span>{createLabel}</span>
108
+ </DropdownMenuItem>
109
+ </>
110
+ )}
111
+ </DropdownMenuContent>
112
+ </DropdownMenu>
113
+ );
114
+ }
@@ -0,0 +1,45 @@
1
+ import { Button } from "#components/ui/button";
2
+ import { cn } from "#utils/cn";
3
+ import { PlusIcon } from "@phosphor-icons/react/Plus";
4
+ import type * as React from "react";
5
+
6
+ export interface PageHeaderProps {
7
+ title: string;
8
+ description?: string;
9
+ actionLabel?: string;
10
+ onAction?: () => void;
11
+ actionIcon?: React.ReactNode;
12
+ children?: React.ReactNode;
13
+ className?: string;
14
+ }
15
+
16
+ export function PageHeader({
17
+ title,
18
+ description,
19
+ actionLabel,
20
+ onAction,
21
+ actionIcon,
22
+ children,
23
+ className,
24
+ }: PageHeaderProps) {
25
+ return (
26
+ <div
27
+ data-slot="page-header"
28
+ className={cn("flex items-center justify-between mb-6", className)}
29
+ >
30
+ <div>
31
+ <h1 className="text-2xl font-bold">{title}</h1>
32
+ {description && (
33
+ <p className="text-muted-foreground mt-2">{description}</p>
34
+ )}
35
+ </div>
36
+ {children}
37
+ {actionLabel && onAction && (
38
+ <Button onClick={onAction}>
39
+ {actionIcon ?? <PlusIcon className="size-4" />}
40
+ {actionLabel}
41
+ </Button>
42
+ )}
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,52 @@
1
+ import { Button } from "#components/ui/button";
2
+ import { CaretLeftIcon } from "@phosphor-icons/react/CaretLeft";
3
+ import { CaretRightIcon } from "@phosphor-icons/react/CaretRight";
4
+
5
+ export interface PaginationProps {
6
+ total: number;
7
+ limit: number;
8
+ offset: number;
9
+ onPageChange: (offset: number) => void;
10
+ }
11
+
12
+ export function Pagination({
13
+ total,
14
+ limit,
15
+ offset,
16
+ onPageChange,
17
+ }: PaginationProps) {
18
+ if (total <= limit) return null;
19
+
20
+ const currentPage = Math.floor(offset / limit) + 1;
21
+ const totalPages = Math.ceil(total / limit);
22
+ const start = offset + 1;
23
+ const end = Math.min(offset + limit, total);
24
+
25
+ return (
26
+ <div data-slot="pagination" className="flex items-center justify-between">
27
+ <p className="text-sm text-muted-foreground">
28
+ Showing {start}-{end} of {total}
29
+ </p>
30
+ <div className="flex gap-2">
31
+ <Button
32
+ variant="outline"
33
+ size="sm"
34
+ disabled={currentPage <= 1}
35
+ onClick={() => onPageChange(offset - limit)}
36
+ >
37
+ <CaretLeftIcon className="size-4" />
38
+ Previous
39
+ </Button>
40
+ <Button
41
+ variant="outline"
42
+ size="sm"
43
+ disabled={currentPage >= totalPages}
44
+ onClick={() => onPageChange(offset + limit)}
45
+ >
46
+ Next
47
+ <CaretRightIcon className="size-4" />
48
+ </Button>
49
+ </div>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,151 @@
1
+ "use client";
2
+
3
+ import { Button } from "#components/ui/button";
4
+ import { Input } from "#components/ui/input";
5
+ import { cn } from "#utils/cn";
6
+ import { useState } from "react";
7
+
8
+ export interface PillSelectOption<T extends string = string> {
9
+ value: T;
10
+ label: string;
11
+ }
12
+
13
+ export interface PillSelectProps<T extends string = string> {
14
+ options: PillSelectOption<T>[];
15
+ value?: T;
16
+ onChange: (value: T | undefined) => void;
17
+ disabled?: boolean;
18
+ showSearch?: boolean;
19
+ searchPlaceholder?: string;
20
+ allowDeselect?: boolean;
21
+ className?: string;
22
+ /** Max number of pills to show before collapsing. When set, only `defaultVisible` options are shown initially. */
23
+ maxVisible?: number;
24
+ /** Which option values to show by default when collapsed. Falls back to first `maxVisible` options. */
25
+ defaultVisible?: T[];
26
+ }
27
+
28
+ export function PillSelect<T extends string = string>({
29
+ options,
30
+ value,
31
+ onChange,
32
+ disabled = false,
33
+ showSearch = false,
34
+ searchPlaceholder = "Search...",
35
+ allowDeselect = true,
36
+ className,
37
+ maxVisible,
38
+ defaultVisible,
39
+ }: PillSelectProps<T>) {
40
+ const [searchTerm, setSearchTerm] = useState("");
41
+ const [showAll, setShowAll] = useState(false);
42
+
43
+ // Filter options based on search term
44
+ const filteredOptions = showSearch
45
+ ? options.filter((opt) =>
46
+ opt.label.toLowerCase().includes(searchTerm.toLowerCase()),
47
+ )
48
+ : options;
49
+
50
+ // Determine visible options when collapsed
51
+ const isSearching = showSearch && searchTerm.length > 0;
52
+ const shouldCollapse = maxVisible && !showAll && !isSearching && filteredOptions.length > maxVisible;
53
+
54
+ const visibleOptions = shouldCollapse
55
+ ? (() => {
56
+ // Always include the selected value in visible options
57
+ const defaultSet = defaultVisible
58
+ ? filteredOptions.filter((opt) => defaultVisible.includes(opt.value))
59
+ : filteredOptions.slice(0, maxVisible);
60
+
61
+ if (value && !defaultSet.some((opt) => opt.value === value)) {
62
+ const selectedOpt = filteredOptions.find((opt) => opt.value === value);
63
+ if (selectedOpt) {
64
+ return [...defaultSet, selectedOpt];
65
+ }
66
+ }
67
+ return defaultSet;
68
+ })()
69
+ : filteredOptions;
70
+
71
+ const hiddenCount = shouldCollapse ? filteredOptions.length - visibleOptions.length : 0;
72
+
73
+ // Handle option click
74
+ const handleClick = (optionValue: T) => {
75
+ if (disabled) return;
76
+
77
+ // Allow deselect if enabled and clicking the already selected option
78
+ if (allowDeselect && value === optionValue) {
79
+ onChange(undefined);
80
+ } else {
81
+ onChange(optionValue);
82
+ // Clear search after selection
83
+ if (showSearch) {
84
+ setSearchTerm("");
85
+ }
86
+ }
87
+ };
88
+
89
+ return (
90
+ <div className={cn("space-y-3", className)}>
91
+ {/* Search input */}
92
+ {showSearch && (
93
+ <Input
94
+ type="text"
95
+ placeholder={searchPlaceholder}
96
+ value={searchTerm}
97
+ onChange={(e) => setSearchTerm(e.target.value)}
98
+ disabled={disabled}
99
+ className="w-full"
100
+ />
101
+ )}
102
+
103
+ {/* Pills grid */}
104
+ <div className="flex flex-wrap gap-2">
105
+ {visibleOptions.length > 0 ? (
106
+ <>
107
+ {visibleOptions.map((option) => (
108
+ <Button
109
+ key={option.value}
110
+ type="button"
111
+ variant={value === option.value ? "default" : "outline"}
112
+ size="sm"
113
+ onClick={() => handleClick(option.value)}
114
+ disabled={disabled}
115
+ className="transition-all focus-visible:ring-0 focus-visible:border-primary/90 focus-visible:ring-offset-0 focus-visible:outline-none"
116
+ >
117
+ {option.label}
118
+ </Button>
119
+ ))}
120
+ {shouldCollapse && hiddenCount > 0 && (
121
+ <Button
122
+ type="button"
123
+ variant="ghost"
124
+ size="sm"
125
+ onClick={() => setShowAll(true)}
126
+ disabled={disabled}
127
+ className="text-muted-foreground"
128
+ >
129
+ +{hiddenCount} more
130
+ </Button>
131
+ )}
132
+ {showAll && maxVisible && !isSearching && (
133
+ <Button
134
+ type="button"
135
+ variant="ghost"
136
+ size="sm"
137
+ onClick={() => setShowAll(false)}
138
+ disabled={disabled}
139
+ className="text-muted-foreground"
140
+ >
141
+ Show less
142
+ </Button>
143
+ )}
144
+ </>
145
+ ) : (
146
+ <p className="text-sm text-muted-foreground py-2">No options found</p>
147
+ )}
148
+ </div>
149
+ </div>
150
+ );
151
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
4
+ import * as React from "react";
5
+ import { cn } from "#utils/cn";
6
+
7
+ const Popover = PopoverPrimitive.Root;
8
+ const PopoverTrigger = PopoverPrimitive.Trigger;
9
+ const PopoverAnchor = PopoverPrimitive.Anchor;
10
+ const PopoverClose = PopoverPrimitive.Close;
11
+
12
+ const PopoverContent = React.forwardRef<
13
+ React.ComponentRef<typeof PopoverPrimitive.Content>,
14
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
15
+ >(
16
+ (
17
+ { className, align = "center", sideOffset = 8, children, ...props },
18
+ ref,
19
+ ) => (
20
+ <PopoverPrimitive.Portal>
21
+ <PopoverPrimitive.Content
22
+ ref={ref}
23
+ align={align}
24
+ sideOffset={sideOffset}
25
+ className={cn(
26
+ "z-50 w-72 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none",
27
+ "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
28
+ "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
29
+ "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
30
+ className,
31
+ )}
32
+ {...props}
33
+ >
34
+ {children}
35
+ </PopoverPrimitive.Content>
36
+ </PopoverPrimitive.Portal>
37
+ ),
38
+ );
39
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName;
40
+
41
+ export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger };
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { Input } from "#components/ui/input";
4
+ import { cn } from "#utils/cn";
5
+ import { MagnifyingGlassIcon } from "@phosphor-icons/react/MagnifyingGlass";
6
+ import { useEffect, useState } from "react";
7
+
8
+ export interface SearchInputProps {
9
+ value: string;
10
+ onChange: (value: string) => void;
11
+ placeholder?: string;
12
+ debounceMs?: number;
13
+ className?: string;
14
+ }
15
+
16
+ export function SearchInput({
17
+ value,
18
+ onChange,
19
+ placeholder = "Search...",
20
+ debounceMs = 300,
21
+ className,
22
+ }: SearchInputProps) {
23
+ const [localValue, setLocalValue] = useState(value);
24
+
25
+ // Sync external value changes
26
+ useEffect(() => {
27
+ setLocalValue(value);
28
+ }, [value]);
29
+
30
+ // Debounce local changes
31
+ useEffect(() => {
32
+ const timeout = setTimeout(() => {
33
+ onChange(localValue);
34
+ }, debounceMs);
35
+ return () => clearTimeout(timeout);
36
+ }, [localValue, onChange, debounceMs]);
37
+
38
+ return (
39
+ <div data-slot="search-input" className={cn("relative", className)}>
40
+ <MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
41
+ <Input
42
+ placeholder={placeholder}
43
+ value={localValue}
44
+ onChange={(e) => setLocalValue(e.target.value)}
45
+ className="pl-9"
46
+ />
47
+ </div>
48
+ );
49
+ }