@handled-ai/design-system 0.18.43 → 0.18.45

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.
@@ -3,7 +3,7 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const badgeVariants: (props?: ({
6
- variant?: "link" | "default" | "secondary" | "destructive" | "outline" | "ghost" | null | undefined;
6
+ variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" | null | undefined;
7
7
  } & class_variance_authority_types.ClassProp) | undefined) => string;
8
8
  declare function Badge({ className, variant, asChild, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & {
9
9
  asChild?: boolean;
@@ -3,7 +3,7 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const buttonVariants: (props?: ({
6
- variant?: "link" | "default" | "secondary" | "destructive" | "outline" | "ghost" | null | undefined;
6
+ variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" | null | undefined;
7
7
  size?: "default" | "sm" | "lg" | "icon" | null | undefined;
8
8
  } & class_variance_authority_types.ClassProp) | undefined) => string;
9
9
  declare function Button({ className, variant, size, asChild, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
@@ -5,15 +5,12 @@ interface FilterOption {
5
5
  label: string;
6
6
  value: string;
7
7
  }
8
- interface DataTableFilterCategory {
8
+ interface DataTableFilterCategoryBase {
9
9
  id: string;
10
10
  label: string;
11
11
  icon: React.ComponentType<{
12
12
  className?: string;
13
13
  }>;
14
- options: (string | FilterOption)[];
15
- /** Filter behavior. Defaults to "multi" (checkbox multi-select). */
16
- type?: "multi" | "single" | "boolean";
17
14
  /**
18
15
  * Submenu search behavior. Defaults to the DataTableFilter
19
16
  * optionSearchThreshold prop. Use true to always show search or false to
@@ -23,6 +20,20 @@ interface DataTableFilterCategory {
23
20
  threshold?: number;
24
21
  };
25
22
  }
23
+ interface DataTableOptionFilterCategory extends DataTableFilterCategoryBase {
24
+ options: (string | FilterOption)[];
25
+ /** Filter behavior. Defaults to "multi" (checkbox multi-select). */
26
+ type?: "multi" | "single" | "boolean";
27
+ }
28
+ interface DataTableTextFilterCategory extends DataTableFilterCategoryBase {
29
+ /** Free-text filter behavior. Renders a top-level submenu with a text input. */
30
+ type: "text";
31
+ /** Placeholder shown in the text filter input. */
32
+ valuePlaceholder?: string;
33
+ /** Not used for text filters; optional for backwards-compatible category shapes. */
34
+ options?: (string | FilterOption)[];
35
+ }
36
+ type DataTableFilterCategory = DataTableOptionFilterCategory | DataTableTextFilterCategory;
26
37
  interface DataTableFilterProps {
27
38
  categories: DataTableFilterCategory[];
28
39
  selectedFilters: Record<string, string[]>;
@@ -44,7 +55,11 @@ interface DataTableFilterProps {
44
55
  onConditionFiltersChange?: (conditions: ConditionFilterValue[]) => void;
45
56
  /** Dropdown entry label for the condition-builder panel. Default: "Add filter". */
46
57
  conditionBuilderLabel?: string;
58
+ /** Active free-text filters keyed by category id. */
59
+ textFilters?: Record<string, string>;
60
+ /** Callback when a free-text filter value is applied or cleared. */
61
+ onTextFilterChange?: (categoryId: string, value: string) => void;
47
62
  }
48
- declare function DataTableFilter({ categories, selectedFilters, onToggleFilter, className, optionSearchThreshold, presetFilters, onTogglePreset, presetLabel, conditionFields, conditionFilters, onConditionFiltersChange, conditionBuilderLabel, }: DataTableFilterProps): React.JSX.Element;
63
+ declare function DataTableFilter({ categories, selectedFilters, onToggleFilter, className, optionSearchThreshold, presetFilters, onTogglePreset, presetLabel, conditionFields, conditionFilters, onConditionFiltersChange, conditionBuilderLabel, textFilters, onTextFilterChange, }: DataTableFilterProps): React.JSX.Element;
49
64
 
50
- export { DataTableFilter, type DataTableFilterCategory, type DataTableFilterProps, type FilterOption };
65
+ export { DataTableFilter, type DataTableFilterCategory, type DataTableFilterProps, type DataTableOptionFilterCategory, type DataTableTextFilterCategory, type FilterOption };
@@ -45,6 +45,110 @@ function getOptionValue(option) {
45
45
  function getOptionLabel(option) {
46
46
  return typeof option === "string" ? option : option.label;
47
47
  }
48
+ function isTextFilterCategory(category) {
49
+ return category.type === "text";
50
+ }
51
+ function TextFilterSubmenu({
52
+ category,
53
+ value,
54
+ onValueChange
55
+ }) {
56
+ var _a;
57
+ const [draftValue, setDraftValue] = React.useState(value);
58
+ React.useEffect(() => {
59
+ setDraftValue(value);
60
+ }, [value]);
61
+ const active = value.trim().length > 0;
62
+ const applyValue = React.useCallback(() => {
63
+ onValueChange == null ? void 0 : onValueChange(category.id, draftValue.trim());
64
+ }, [category.id, draftValue, onValueChange]);
65
+ return /* @__PURE__ */ jsxs(
66
+ DropdownMenuSub,
67
+ {
68
+ onOpenChange: (open) => {
69
+ if (!open) {
70
+ setDraftValue(value);
71
+ }
72
+ },
73
+ children: [
74
+ /* @__PURE__ */ jsxs(
75
+ DropdownMenuSubTrigger,
76
+ {
77
+ className: cn(
78
+ "cursor-pointer py-1.5 text-xs",
79
+ active && "text-brand-purple"
80
+ ),
81
+ children: [
82
+ /* @__PURE__ */ jsx(
83
+ category.icon,
84
+ {
85
+ className: cn(
86
+ "mr-2 h-3.5 w-3.5 text-muted-foreground",
87
+ active && "text-brand-purple"
88
+ )
89
+ }
90
+ ),
91
+ category.label,
92
+ active ? /* @__PURE__ */ jsx(Check, { className: "ml-auto h-4 w-4" }) : null
93
+ ]
94
+ }
95
+ ),
96
+ /* @__PURE__ */ jsx(DropdownMenuSubContent, { className: "w-64 p-2", children: /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
97
+ /* @__PURE__ */ jsx(
98
+ "input",
99
+ {
100
+ "aria-label": category.label,
101
+ className: "h-8 w-full rounded-md bg-muted/50 px-2 py-1 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted",
102
+ placeholder: (_a = category.valuePlaceholder) != null ? _a : `Enter ${category.label.toLowerCase()}...`,
103
+ value: draftValue,
104
+ onChange: (event) => setDraftValue(event.target.value),
105
+ onClick: (event) => event.stopPropagation(),
106
+ onKeyDown: (event) => {
107
+ event.stopPropagation();
108
+ if (event.key === "Enter") {
109
+ event.preventDefault();
110
+ applyValue();
111
+ }
112
+ }
113
+ }
114
+ ),
115
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-end gap-2", children: [
116
+ active ? /* @__PURE__ */ jsx(
117
+ Button,
118
+ {
119
+ type: "button",
120
+ variant: "ghost",
121
+ size: "sm",
122
+ className: "h-7 px-2 text-xs",
123
+ onClick: (event) => {
124
+ event.preventDefault();
125
+ event.stopPropagation();
126
+ setDraftValue("");
127
+ onValueChange == null ? void 0 : onValueChange(category.id, "");
128
+ },
129
+ children: "Clear"
130
+ }
131
+ ) : null,
132
+ /* @__PURE__ */ jsx(
133
+ Button,
134
+ {
135
+ type: "button",
136
+ size: "sm",
137
+ className: "h-7 px-2 text-xs",
138
+ onClick: (event) => {
139
+ event.preventDefault();
140
+ event.stopPropagation();
141
+ applyValue();
142
+ },
143
+ children: "Apply"
144
+ }
145
+ )
146
+ ] })
147
+ ] }) })
148
+ ]
149
+ }
150
+ );
151
+ }
48
152
  function DataTableFilter({
49
153
  categories,
50
154
  selectedFilters,
@@ -57,7 +161,9 @@ function DataTableFilter({
57
161
  conditionFields = [],
58
162
  conditionFilters = [],
59
163
  onConditionFiltersChange,
60
- conditionBuilderLabel = "Add filter"
164
+ conditionBuilderLabel = "Add filter",
165
+ textFilters = {},
166
+ onTextFilterChange
61
167
  }) {
62
168
  const [query, setQuery] = React.useState("");
63
169
  const [subQueries, setSubQueries] = React.useState({});
@@ -72,6 +178,9 @@ function DataTableFilter({
72
178
  if (category.label.toLowerCase().includes(normalized)) {
73
179
  return true;
74
180
  }
181
+ if (isTextFilterCategory(category)) {
182
+ return false;
183
+ }
75
184
  return category.options.some(
76
185
  (option) => getOptionLabel(option).toLowerCase().includes(normalized)
77
186
  );
@@ -89,8 +198,15 @@ function DataTableFilter({
89
198
  (count, selected) => count + selected.length,
90
199
  0
91
200
  );
92
- return userCount + conditionFilters.length;
93
- }, [selectedFilters, conditionFilters.length]);
201
+ const textCount = categories.reduce((count, category) => {
202
+ var _a;
203
+ if (!isTextFilterCategory(category)) {
204
+ return count;
205
+ }
206
+ return ((_a = textFilters[category.id]) == null ? void 0 : _a.trim()) ? count + 1 : count;
207
+ }, 0);
208
+ return userCount + conditionFilters.length + textCount;
209
+ }, [categories, selectedFilters, conditionFilters.length, textFilters]);
94
210
  const presetChips = React.useMemo(() => {
95
211
  var _a, _b;
96
212
  if (!presetFilters) return [];
@@ -98,9 +214,7 @@ function DataTableFilter({
98
214
  for (const [categoryId, values] of Object.entries(presetFilters)) {
99
215
  const category = categories.find((c) => c.id === categoryId);
100
216
  for (const value of values) {
101
- const option = category == null ? void 0 : category.options.find(
102
- (opt) => getOptionValue(opt) === value
103
- );
217
+ const option = category && !isTextFilterCategory(category) ? category.options.find((opt) => getOptionValue(opt) === value) : void 0;
104
218
  const label = option ? getOptionLabel(option) : value;
105
219
  const active = (_b = (_a = selectedFilters[categoryId]) == null ? void 0 : _a.includes(value)) != null ? _b : false;
106
220
  chips.push({ categoryId, value, label, active });
@@ -142,7 +256,7 @@ function DataTableFilter({
142
256
  ] }) }),
143
257
  /* @__PURE__ */ jsxs("div", { className: "max-h-[320px] overflow-y-auto p-1", children: [
144
258
  visibleCategories.map((category) => {
145
- var _a, _b, _c, _d, _e;
259
+ var _a, _b, _c, _d, _e, _f;
146
260
  const filterType = (_a = category.type) != null ? _a : "multi";
147
261
  if (filterType === "boolean") {
148
262
  const active = (_c = (_b = selectedFilters[category.id]) == null ? void 0 : _b.includes("true")) != null ? _c : false;
@@ -166,7 +280,18 @@ function DataTableFilter({
166
280
  category.id
167
281
  );
168
282
  }
169
- const subQuery = ((_d = subQueries[category.id]) != null ? _d : "").trim().toLowerCase();
283
+ if (isTextFilterCategory(category)) {
284
+ return /* @__PURE__ */ jsx(
285
+ TextFilterSubmenu,
286
+ {
287
+ category,
288
+ value: (_d = textFilters[category.id]) != null ? _d : "",
289
+ onValueChange: onTextFilterChange
290
+ },
291
+ category.id
292
+ );
293
+ }
294
+ const subQuery = ((_e = subQueries[category.id]) != null ? _e : "").trim().toLowerCase();
170
295
  const filteredOptions = subQuery ? category.options.filter(
171
296
  (opt) => getOptionLabel(opt).toLowerCase().includes(subQuery)
172
297
  ) : category.options;
@@ -200,7 +325,7 @@ function DataTableFilter({
200
325
  {
201
326
  className: "h-7 w-full rounded-md bg-muted/50 py-1 pr-2 pl-7 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted",
202
327
  placeholder: "Search...",
203
- value: (_e = subQueries[category.id]) != null ? _e : "",
328
+ value: (_f = subQueries[category.id]) != null ? _f : "",
204
329
  onChange: (e) => setSubQueries((prev) => __spreadProps(__spreadValues({}, prev), { [category.id]: e.target.value })),
205
330
  onClick: (e) => e.stopPropagation(),
206
331
  onKeyDown: (e) => {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/data-table-filter.tsx"],"sourcesContent":["\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ListFilter, Plus, Search } from \"lucide-react\"\n\nimport { Popover as PopoverPrimitive } from \"radix-ui\"\n\nimport { cn } from \"../lib/utils\"\nimport { Button } from \"./button\"\nimport {\n DataTableConditionFilter,\n shouldShowOptionSearch,\n type ConditionFieldDef,\n type ConditionFilterValue,\n} from \"./data-table-condition-filter\"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger,\n} from \"./dropdown-menu\"\n\nexport interface FilterOption {\n label: string\n value: string\n}\n\nexport interface DataTableFilterCategory {\n id: string\n label: string\n icon: React.ComponentType<{ className?: string }>\n options: (string | FilterOption)[]\n /** Filter behavior. Defaults to \"multi\" (checkbox multi-select). */\n type?: \"multi\" | \"single\" | \"boolean\"\n /**\n * Submenu search behavior. Defaults to the DataTableFilter\n * optionSearchThreshold prop. Use true to always show search or false to\n * hide it for a specific category.\n */\n searchable?: boolean | { threshold?: number }\n}\n\nfunction getOptionValue(option: string | FilterOption): string {\n return typeof option === \"string\" ? option : option.value\n}\nfunction getOptionLabel(option: string | FilterOption): string {\n return typeof option === \"string\" ? option : option.label\n}\n\nexport interface DataTableFilterProps {\n categories: DataTableFilterCategory[]\n selectedFilters: Record<string, string[]>\n onToggleFilter: (categoryId: string, option: string) => void\n className?: string\n /** Minimum number of options before showing the sub-menu search input. Defaults to 8. */\n optionSearchThreshold?: number\n /** Filters applied by default. Shown as distinct chips that can be toggled off but not dismissed. */\n presetFilters?: Record<string, string[]>\n /** Callback when a preset filter is toggled on/off. */\n onTogglePreset?: (categoryId: string, option: string) => void\n /** Label shown on preset chips to distinguish from user-applied filters. Default: \"Default\". */\n presetLabel?: string\n /** Fields exposed in the unified condition-builder panel. */\n conditionFields?: ConditionFieldDef[]\n /** Active builder-managed field/operator/value conditions. */\n conditionFilters?: ConditionFilterValue[]\n /** Callback when builder-managed conditions are applied, removed, or cleared. */\n onConditionFiltersChange?: (conditions: ConditionFilterValue[]) => void\n /** Dropdown entry label for the condition-builder panel. Default: \"Add filter\". */\n conditionBuilderLabel?: string\n}\n\nexport function DataTableFilter({\n categories,\n selectedFilters,\n onToggleFilter,\n className,\n optionSearchThreshold = 8,\n presetFilters,\n onTogglePreset,\n presetLabel = \"Default\",\n conditionFields = [],\n conditionFilters = [],\n onConditionFiltersChange,\n conditionBuilderLabel = \"Add filter\",\n}: DataTableFilterProps) {\n const [query, setQuery] = React.useState(\"\")\n const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})\n const [conditionBuilderOpen, setConditionBuilderOpen] = React.useState(false)\n const hasConditionBuilder = conditionFields.length > 0\n\n const visibleCategories = React.useMemo(() => {\n const normalized = query.trim().toLowerCase()\n if (!normalized) {\n return categories\n }\n\n return categories.filter((category) => {\n if (category.label.toLowerCase().includes(normalized)) {\n return true\n }\n\n return category.options.some((option) =>\n getOptionLabel(option).toLowerCase().includes(normalized)\n )\n })\n }, [categories, query])\n\n /** Check if a specific option is a preset filter */\n const isPresetOption = React.useCallback(\n (categoryId: string, value: string): boolean => {\n return presetFilters?.[categoryId]?.includes(value) ?? false\n },\n [presetFilters]\n )\n\n const activeCount = React.useMemo(() => {\n const userCount = Object.values(selectedFilters).reduce(\n (count, selected) => count + selected.length,\n 0\n )\n\n return userCount + conditionFilters.length\n }, [selectedFilters, conditionFilters.length])\n\n /** Collect all preset chips to render */\n const presetChips = React.useMemo(() => {\n if (!presetFilters) return []\n\n const chips: { categoryId: string; value: string; label: string; active: boolean }[] = []\n\n for (const [categoryId, values] of Object.entries(presetFilters)) {\n const category = categories.find((c) => c.id === categoryId)\n for (const value of values) {\n const option = category?.options.find(\n (opt) => getOptionValue(opt) === value\n )\n const label = option ? getOptionLabel(option) : value\n const active = selectedFilters[categoryId]?.includes(value) ?? false\n chips.push({ categoryId, value, label, active })\n }\n }\n\n return chips\n }, [presetFilters, categories, selectedFilters])\n\n const triggerButton = (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"outline\"\n size=\"sm\"\n className={cn(\n \"h-8 gap-2 rounded-md border-border/60 bg-background text-xs font-normal shadow-none hover:bg-muted/50\",\n className\n )}\n >\n <ListFilter className=\"h-3.5 w-3.5\" />\n Filter\n {activeCount > 0 ? (\n <span className=\"rounded bg-muted px-1.5 py-0 text-[10px] font-semibold text-foreground\">\n {activeCount}\n </span>\n ) : null}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"start\" className=\"w-[240px] p-0\">\n <div className=\"sticky top-0 z-10 border-b border-border bg-popover p-2\">\n <div className=\"relative\">\n <Search className=\"absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground\" />\n <input\n className=\"h-8 w-full rounded-md bg-muted/50 py-1 pr-2 pl-7 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted\"\n placeholder=\"Search filters...\"\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onClick={(event) => event.stopPropagation()}\n onKeyDown={(event) => event.stopPropagation()}\n />\n </div>\n </div>\n\n <div className=\"max-h-[320px] overflow-y-auto p-1\">\n {visibleCategories.map((category) => {\n const filterType = category.type ?? \"multi\"\n\n /* ── Boolean toggle ─────────────────────────────────── */\n if (filterType === \"boolean\") {\n const active = selectedFilters[category.id]?.includes(\"true\") ?? false\n return (\n <DropdownMenuItem\n key={category.id}\n className={cn(\n \"cursor-pointer py-1.5 text-xs\",\n active && \"text-brand-purple\"\n )}\n onSelect={(event) => {\n event.preventDefault()\n onToggleFilter(category.id, \"true\")\n }}\n >\n <category.icon className=\"mr-2 h-3.5 w-3.5\" />\n {category.label}\n {active ? <Check className=\"ml-auto h-4 w-4\" /> : null}\n </DropdownMenuItem>\n )\n }\n\n /* ── Sub-menu (single / multi) ──────────────────────── */\n const subQuery = (subQueries[category.id] ?? \"\").trim().toLowerCase()\n const filteredOptions = subQuery\n ? category.options.filter((opt) =>\n getOptionLabel(opt).toLowerCase().includes(subQuery)\n )\n : category.options\n const shouldShowSubmenuSearch = shouldShowOptionSearch(\n category.searchable,\n category.options.length,\n optionSearchThreshold,\n )\n\n return (\n <DropdownMenuSub\n key={category.id}\n onOpenChange={(open) => {\n if (!open) {\n setSubQueries((prev) => {\n const next = { ...prev }\n delete next[category.id]\n return next\n })\n }\n }}\n >\n <DropdownMenuSubTrigger className=\"cursor-pointer py-1.5 text-xs\">\n <category.icon className=\"mr-2 h-3.5 w-3.5 text-muted-foreground\" />\n {category.label}\n </DropdownMenuSubTrigger>\n <DropdownMenuSubContent className=\"max-h-[320px] w-52 overflow-y-auto p-1\">\n {/* Submenu search — shown for long lists or categories that opt in. */}\n {shouldShowSubmenuSearch && (\n <div className=\"sticky top-0 z-10 border-b border-border bg-popover p-1.5\">\n <div className=\"relative\">\n <Search className=\"absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground\" />\n <input\n className=\"h-7 w-full rounded-md bg-muted/50 py-1 pr-2 pl-7 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted\"\n placeholder=\"Search...\"\n value={subQueries[category.id] ?? \"\"}\n onChange={(e) =>\n setSubQueries((prev) => ({ ...prev, [category.id]: e.target.value }))\n }\n onClick={(e) => e.stopPropagation()}\n onKeyDown={(e) => {\n // Allow navigation keys to propagate to Radix menu handling\n // so keyboard users can move to and select filtered options.\n const navKeys = [\"ArrowDown\", \"ArrowUp\", \"Enter\", \"Escape\", \"Tab\"]\n if (!navKeys.includes(e.key)) {\n e.stopPropagation()\n }\n }}\n />\n </div>\n </div>\n )}\n {/* Filtered options */}\n {filteredOptions.map((option) => {\n const value = getOptionValue(option)\n const label = getOptionLabel(option)\n const selected = selectedFilters[category.id]?.includes(value) ?? false\n const isPreset = isPresetOption(category.id, value)\n return (\n <DropdownMenuItem\n key={value}\n className=\"cursor-pointer justify-between text-xs\"\n onSelect={(event) => {\n event.preventDefault()\n onToggleFilter(category.id, value)\n }}\n >\n {label}\n {selected ? (\n isPreset ? (\n <span className=\"text-brand-purple text-[10px] font-semibold\">\n {presetLabel}\n </span>\n ) : filterType === \"single\" ? (\n <span className=\"h-1.5 w-1.5 rounded-full bg-current\" />\n ) : (\n <span className=\"text-[10px] font-semibold text-brand-purple\">\n Applied\n </span>\n )\n ) : null}\n </DropdownMenuItem>\n )\n })}\n {filteredOptions.length === 0 && category.options.length > 0 && (\n <div className=\"p-2 text-center text-xs text-muted-foreground\">\n No matches\n </div>\n )}\n </DropdownMenuSubContent>\n </DropdownMenuSub>\n )\n })}\n\n {visibleCategories.length === 0 ? (\n <div className=\"p-2 text-center text-xs text-muted-foreground\">\n No filters found\n </div>\n ) : null}\n </div>\n\n {hasConditionBuilder ? (\n <div className=\"border-t border-border p-1\">\n <PopoverPrimitive.Root\n open={conditionBuilderOpen}\n onOpenChange={setConditionBuilderOpen}\n >\n <PopoverPrimitive.Trigger asChild>\n <DropdownMenuItem\n className=\"cursor-pointer py-1.5 text-xs\"\n onSelect={(event) => {\n event.preventDefault()\n setConditionBuilderOpen(true)\n }}\n >\n <Plus className=\"mr-2 h-3.5 w-3.5 text-muted-foreground\" />\n {conditionBuilderLabel}\n {conditionFilters.length > 0 ? (\n <span className=\"ml-auto rounded bg-muted px-1.5 py-0 text-[10px] font-semibold\">\n {conditionFilters.length}\n </span>\n ) : null}\n </DropdownMenuItem>\n </PopoverPrimitive.Trigger>\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n align=\"start\"\n side=\"right\"\n sideOffset={8}\n className=\"z-50 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\"\n onEscapeKeyDown={() => setConditionBuilderOpen(false)}\n onInteractOutside={(event) => {\n const target = event.target as HTMLElement | null\n if (target?.closest('[data-slot=\"dropdown-menu-content\"]')) {\n event.preventDefault()\n }\n }}\n >\n <DataTableConditionFilter\n fields={conditionFields}\n conditions={conditionFilters}\n onConditionsChange={onConditionFiltersChange ?? (() => {})}\n />\n </PopoverPrimitive.Content>\n </PopoverPrimitive.Portal>\n </PopoverPrimitive.Root>\n </div>\n ) : null}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n\n // If there are preset chips, wrap trigger + chips together\n if (presetChips.length > 0) {\n return (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n {triggerButton}\n {presetChips.map((chip) => (\n <button\n key={`${chip.categoryId}-${chip.value}`}\n type=\"button\"\n onClick={() => onTogglePreset?.(chip.categoryId, chip.value)}\n className={cn(\n \"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium transition-colors\",\n chip.active\n ? \"border-dashed border-brand-purple/30 bg-brand-purple/5 text-brand-purple/80\"\n : \"border-border/40 bg-transparent text-muted-foreground/60 line-through\"\n )}\n >\n <span className=\"text-brand-purple/50 text-[10px]\">\n {presetLabel}:{\" \"}\n </span>\n {chip.label}\n </button>\n ))}\n </div>\n )\n }\n\n return triggerButton\n}\n"],"mappings":"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","names":["_a","_b"]}
1
+ {"version":3,"sources":["../../src/components/data-table-filter.tsx"],"sourcesContent":["\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ListFilter, Plus, Search } from \"lucide-react\"\n\nimport { Popover as PopoverPrimitive } from \"radix-ui\"\n\nimport { cn } from \"../lib/utils\"\nimport { Button } from \"./button\"\nimport {\n DataTableConditionFilter,\n shouldShowOptionSearch,\n type ConditionFieldDef,\n type ConditionFilterValue,\n} from \"./data-table-condition-filter\"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger,\n} from \"./dropdown-menu\"\n\nexport interface FilterOption {\n label: string\n value: string\n}\n\ninterface DataTableFilterCategoryBase {\n id: string\n label: string\n icon: React.ComponentType<{ className?: string }>\n /**\n * Submenu search behavior. Defaults to the DataTableFilter\n * optionSearchThreshold prop. Use true to always show search or false to\n * hide it for a specific category.\n */\n searchable?: boolean | { threshold?: number }\n}\n\nexport interface DataTableOptionFilterCategory extends DataTableFilterCategoryBase {\n options: (string | FilterOption)[]\n /** Filter behavior. Defaults to \"multi\" (checkbox multi-select). */\n type?: \"multi\" | \"single\" | \"boolean\"\n}\n\nexport interface DataTableTextFilterCategory extends DataTableFilterCategoryBase {\n /** Free-text filter behavior. Renders a top-level submenu with a text input. */\n type: \"text\"\n /** Placeholder shown in the text filter input. */\n valuePlaceholder?: string\n /** Not used for text filters; optional for backwards-compatible category shapes. */\n options?: (string | FilterOption)[]\n}\n\nexport type DataTableFilterCategory =\n | DataTableOptionFilterCategory\n | DataTableTextFilterCategory\n\nfunction getOptionValue(option: string | FilterOption): string {\n return typeof option === \"string\" ? option : option.value\n}\nfunction getOptionLabel(option: string | FilterOption): string {\n return typeof option === \"string\" ? option : option.label\n}\n\nfunction isTextFilterCategory(\n category: DataTableFilterCategory\n): category is DataTableTextFilterCategory {\n return category.type === \"text\"\n}\n\nfunction TextFilterSubmenu({\n category,\n value,\n onValueChange,\n}: {\n category: DataTableTextFilterCategory\n value: string\n onValueChange?: (categoryId: string, value: string) => void\n}) {\n const [draftValue, setDraftValue] = React.useState(value)\n\n React.useEffect(() => {\n setDraftValue(value)\n }, [value])\n\n const active = value.trim().length > 0\n const applyValue = React.useCallback(() => {\n onValueChange?.(category.id, draftValue.trim())\n }, [category.id, draftValue, onValueChange])\n\n return (\n <DropdownMenuSub\n onOpenChange={(open) => {\n if (!open) {\n setDraftValue(value)\n }\n }}\n >\n <DropdownMenuSubTrigger\n className={cn(\n \"cursor-pointer py-1.5 text-xs\",\n active && \"text-brand-purple\"\n )}\n >\n <category.icon\n className={cn(\n \"mr-2 h-3.5 w-3.5 text-muted-foreground\",\n active && \"text-brand-purple\"\n )}\n />\n {category.label}\n {active ? <Check className=\"ml-auto h-4 w-4\" /> : null}\n </DropdownMenuSubTrigger>\n <DropdownMenuSubContent className=\"w-64 p-2\">\n <div className=\"space-y-2\">\n <input\n aria-label={category.label}\n className=\"h-8 w-full rounded-md bg-muted/50 px-2 py-1 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted\"\n placeholder={\n category.valuePlaceholder ??\n `Enter ${category.label.toLowerCase()}...`\n }\n value={draftValue}\n onChange={(event) => setDraftValue(event.target.value)}\n onClick={(event) => event.stopPropagation()}\n onKeyDown={(event) => {\n event.stopPropagation()\n if (event.key === \"Enter\") {\n event.preventDefault()\n applyValue()\n }\n }}\n />\n <div className=\"flex items-center justify-end gap-2\">\n {active ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-7 px-2 text-xs\"\n onClick={(event) => {\n event.preventDefault()\n event.stopPropagation()\n setDraftValue(\"\")\n onValueChange?.(category.id, \"\")\n }}\n >\n Clear\n </Button>\n ) : null}\n <Button\n type=\"button\"\n size=\"sm\"\n className=\"h-7 px-2 text-xs\"\n onClick={(event) => {\n event.preventDefault()\n event.stopPropagation()\n applyValue()\n }}\n >\n Apply\n </Button>\n </div>\n </div>\n </DropdownMenuSubContent>\n </DropdownMenuSub>\n )\n}\n\nexport interface DataTableFilterProps {\n categories: DataTableFilterCategory[]\n selectedFilters: Record<string, string[]>\n onToggleFilter: (categoryId: string, option: string) => void\n className?: string\n /** Minimum number of options before showing the sub-menu search input. Defaults to 8. */\n optionSearchThreshold?: number\n /** Filters applied by default. Shown as distinct chips that can be toggled off but not dismissed. */\n presetFilters?: Record<string, string[]>\n /** Callback when a preset filter is toggled on/off. */\n onTogglePreset?: (categoryId: string, option: string) => void\n /** Label shown on preset chips to distinguish from user-applied filters. Default: \"Default\". */\n presetLabel?: string\n /** Fields exposed in the unified condition-builder panel. */\n conditionFields?: ConditionFieldDef[]\n /** Active builder-managed field/operator/value conditions. */\n conditionFilters?: ConditionFilterValue[]\n /** Callback when builder-managed conditions are applied, removed, or cleared. */\n onConditionFiltersChange?: (conditions: ConditionFilterValue[]) => void\n /** Dropdown entry label for the condition-builder panel. Default: \"Add filter\". */\n conditionBuilderLabel?: string\n /** Active free-text filters keyed by category id. */\n textFilters?: Record<string, string>\n /** Callback when a free-text filter value is applied or cleared. */\n onTextFilterChange?: (categoryId: string, value: string) => void\n}\n\nexport function DataTableFilter({\n categories,\n selectedFilters,\n onToggleFilter,\n className,\n optionSearchThreshold = 8,\n presetFilters,\n onTogglePreset,\n presetLabel = \"Default\",\n conditionFields = [],\n conditionFilters = [],\n onConditionFiltersChange,\n conditionBuilderLabel = \"Add filter\",\n textFilters = {},\n onTextFilterChange,\n}: DataTableFilterProps) {\n const [query, setQuery] = React.useState(\"\")\n const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})\n const [conditionBuilderOpen, setConditionBuilderOpen] = React.useState(false)\n const hasConditionBuilder = conditionFields.length > 0\n\n const visibleCategories = React.useMemo(() => {\n const normalized = query.trim().toLowerCase()\n if (!normalized) {\n return categories\n }\n\n return categories.filter((category) => {\n if (category.label.toLowerCase().includes(normalized)) {\n return true\n }\n\n if (isTextFilterCategory(category)) {\n return false\n }\n\n return category.options.some((option) =>\n getOptionLabel(option).toLowerCase().includes(normalized)\n )\n })\n }, [categories, query])\n\n /** Check if a specific option is a preset filter */\n const isPresetOption = React.useCallback(\n (categoryId: string, value: string): boolean => {\n return presetFilters?.[categoryId]?.includes(value) ?? false\n },\n [presetFilters]\n )\n\n const activeCount = React.useMemo(() => {\n const userCount = Object.values(selectedFilters).reduce(\n (count, selected) => count + selected.length,\n 0\n )\n\n const textCount = categories.reduce((count, category) => {\n if (!isTextFilterCategory(category)) {\n return count\n }\n\n return textFilters[category.id]?.trim() ? count + 1 : count\n }, 0)\n\n return userCount + conditionFilters.length + textCount\n }, [categories, selectedFilters, conditionFilters.length, textFilters])\n\n /** Collect all preset chips to render */\n const presetChips = React.useMemo(() => {\n if (!presetFilters) return []\n\n const chips: { categoryId: string; value: string; label: string; active: boolean }[] = []\n\n for (const [categoryId, values] of Object.entries(presetFilters)) {\n const category = categories.find((c) => c.id === categoryId)\n for (const value of values) {\n const option = category && !isTextFilterCategory(category)\n ? category.options.find((opt) => getOptionValue(opt) === value)\n : undefined\n const label = option ? getOptionLabel(option) : value\n const active = selectedFilters[categoryId]?.includes(value) ?? false\n chips.push({ categoryId, value, label, active })\n }\n }\n\n return chips\n }, [presetFilters, categories, selectedFilters])\n\n const triggerButton = (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n variant=\"outline\"\n size=\"sm\"\n className={cn(\n \"h-8 gap-2 rounded-md border-border/60 bg-background text-xs font-normal shadow-none hover:bg-muted/50\",\n className\n )}\n >\n <ListFilter className=\"h-3.5 w-3.5\" />\n Filter\n {activeCount > 0 ? (\n <span className=\"rounded bg-muted px-1.5 py-0 text-[10px] font-semibold text-foreground\">\n {activeCount}\n </span>\n ) : null}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"start\" className=\"w-[240px] p-0\">\n <div className=\"sticky top-0 z-10 border-b border-border bg-popover p-2\">\n <div className=\"relative\">\n <Search className=\"absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground\" />\n <input\n className=\"h-8 w-full rounded-md bg-muted/50 py-1 pr-2 pl-7 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted\"\n placeholder=\"Search filters...\"\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onClick={(event) => event.stopPropagation()}\n onKeyDown={(event) => event.stopPropagation()}\n />\n </div>\n </div>\n\n <div className=\"max-h-[320px] overflow-y-auto p-1\">\n {visibleCategories.map((category) => {\n const filterType = category.type ?? \"multi\"\n\n /* ── Boolean toggle ─────────────────────────────────── */\n if (filterType === \"boolean\") {\n const active = selectedFilters[category.id]?.includes(\"true\") ?? false\n return (\n <DropdownMenuItem\n key={category.id}\n className={cn(\n \"cursor-pointer py-1.5 text-xs\",\n active && \"text-brand-purple\"\n )}\n onSelect={(event) => {\n event.preventDefault()\n onToggleFilter(category.id, \"true\")\n }}\n >\n <category.icon className=\"mr-2 h-3.5 w-3.5\" />\n {category.label}\n {active ? <Check className=\"ml-auto h-4 w-4\" /> : null}\n </DropdownMenuItem>\n )\n }\n\n /* ── Free-text submenu ───────────────────────────────── */\n if (isTextFilterCategory(category)) {\n return (\n <TextFilterSubmenu\n key={category.id}\n category={category}\n value={textFilters[category.id] ?? \"\"}\n onValueChange={onTextFilterChange}\n />\n )\n }\n\n /* ── Sub-menu (single / multi) ──────────────────────── */\n const subQuery = (subQueries[category.id] ?? \"\").trim().toLowerCase()\n const filteredOptions = subQuery\n ? category.options.filter((opt) =>\n getOptionLabel(opt).toLowerCase().includes(subQuery)\n )\n : category.options\n const shouldShowSubmenuSearch = shouldShowOptionSearch(\n category.searchable,\n category.options.length,\n optionSearchThreshold,\n )\n\n return (\n <DropdownMenuSub\n key={category.id}\n onOpenChange={(open) => {\n if (!open) {\n setSubQueries((prev) => {\n const next = { ...prev }\n delete next[category.id]\n return next\n })\n }\n }}\n >\n <DropdownMenuSubTrigger className=\"cursor-pointer py-1.5 text-xs\">\n <category.icon className=\"mr-2 h-3.5 w-3.5 text-muted-foreground\" />\n {category.label}\n </DropdownMenuSubTrigger>\n <DropdownMenuSubContent className=\"max-h-[320px] w-52 overflow-y-auto p-1\">\n {/* Submenu search — shown for long lists or categories that opt in. */}\n {shouldShowSubmenuSearch && (\n <div className=\"sticky top-0 z-10 border-b border-border bg-popover p-1.5\">\n <div className=\"relative\">\n <Search className=\"absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground\" />\n <input\n className=\"h-7 w-full rounded-md bg-muted/50 py-1 pr-2 pl-7 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted\"\n placeholder=\"Search...\"\n value={subQueries[category.id] ?? \"\"}\n onChange={(e) =>\n setSubQueries((prev) => ({ ...prev, [category.id]: e.target.value }))\n }\n onClick={(e) => e.stopPropagation()}\n onKeyDown={(e) => {\n // Allow navigation keys to propagate to Radix menu handling\n // so keyboard users can move to and select filtered options.\n const navKeys = [\"ArrowDown\", \"ArrowUp\", \"Enter\", \"Escape\", \"Tab\"]\n if (!navKeys.includes(e.key)) {\n e.stopPropagation()\n }\n }}\n />\n </div>\n </div>\n )}\n {/* Filtered options */}\n {filteredOptions.map((option) => {\n const value = getOptionValue(option)\n const label = getOptionLabel(option)\n const selected = selectedFilters[category.id]?.includes(value) ?? false\n const isPreset = isPresetOption(category.id, value)\n return (\n <DropdownMenuItem\n key={value}\n className=\"cursor-pointer justify-between text-xs\"\n onSelect={(event) => {\n event.preventDefault()\n onToggleFilter(category.id, value)\n }}\n >\n {label}\n {selected ? (\n isPreset ? (\n <span className=\"text-brand-purple text-[10px] font-semibold\">\n {presetLabel}\n </span>\n ) : filterType === \"single\" ? (\n <span className=\"h-1.5 w-1.5 rounded-full bg-current\" />\n ) : (\n <span className=\"text-[10px] font-semibold text-brand-purple\">\n Applied\n </span>\n )\n ) : null}\n </DropdownMenuItem>\n )\n })}\n {filteredOptions.length === 0 && category.options.length > 0 && (\n <div className=\"p-2 text-center text-xs text-muted-foreground\">\n No matches\n </div>\n )}\n </DropdownMenuSubContent>\n </DropdownMenuSub>\n )\n })}\n\n {visibleCategories.length === 0 ? (\n <div className=\"p-2 text-center text-xs text-muted-foreground\">\n No filters found\n </div>\n ) : null}\n </div>\n\n {hasConditionBuilder ? (\n <div className=\"border-t border-border p-1\">\n <PopoverPrimitive.Root\n open={conditionBuilderOpen}\n onOpenChange={setConditionBuilderOpen}\n >\n <PopoverPrimitive.Trigger asChild>\n <DropdownMenuItem\n className=\"cursor-pointer py-1.5 text-xs\"\n onSelect={(event) => {\n event.preventDefault()\n setConditionBuilderOpen(true)\n }}\n >\n <Plus className=\"mr-2 h-3.5 w-3.5 text-muted-foreground\" />\n {conditionBuilderLabel}\n {conditionFilters.length > 0 ? (\n <span className=\"ml-auto rounded bg-muted px-1.5 py-0 text-[10px] font-semibold\">\n {conditionFilters.length}\n </span>\n ) : null}\n </DropdownMenuItem>\n </PopoverPrimitive.Trigger>\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n align=\"start\"\n side=\"right\"\n sideOffset={8}\n className=\"z-50 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\"\n onEscapeKeyDown={() => setConditionBuilderOpen(false)}\n onInteractOutside={(event) => {\n const target = event.target as HTMLElement | null\n if (target?.closest('[data-slot=\"dropdown-menu-content\"]')) {\n event.preventDefault()\n }\n }}\n >\n <DataTableConditionFilter\n fields={conditionFields}\n conditions={conditionFilters}\n onConditionsChange={onConditionFiltersChange ?? (() => {})}\n />\n </PopoverPrimitive.Content>\n </PopoverPrimitive.Portal>\n </PopoverPrimitive.Root>\n </div>\n ) : null}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n\n // If there are preset chips, wrap trigger + chips together\n if (presetChips.length > 0) {\n return (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n {triggerButton}\n {presetChips.map((chip) => (\n <button\n key={`${chip.categoryId}-${chip.value}`}\n type=\"button\"\n onClick={() => onTogglePreset?.(chip.categoryId, chip.value)}\n className={cn(\n \"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium transition-colors\",\n chip.active\n ? \"border-dashed border-brand-purple/30 bg-brand-purple/5 text-brand-purple/80\"\n : \"border-border/40 bg-transparent text-muted-foreground/60 line-through\"\n )}\n >\n <span className=\"text-brand-purple/50 text-[10px]\">\n {presetLabel}:{\" \"}\n </span>\n {chip.label}\n </button>\n ))}\n </div>\n )\n }\n\n return triggerButton\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAsGM,SAME,KANF;AApGN,YAAY,WAAW;AACvB,SAAS,OAAO,YAAY,MAAM,cAAc;AAEhD,SAAS,WAAW,wBAAwB;AAE5C,SAAS,UAAU;AACnB,SAAS,cAAc;AACvB;AAAA,EACE;AAAA,EACA;AAAA,OAGK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAsCP,SAAS,eAAe,QAAuC;AAC7D,SAAO,OAAO,WAAW,WAAW,SAAS,OAAO;AACtD;AACA,SAAS,eAAe,QAAuC;AAC7D,SAAO,OAAO,WAAW,WAAW,SAAS,OAAO;AACtD;AAEA,SAAS,qBACP,UACyC;AACzC,SAAO,SAAS,SAAS;AAC3B;AAEA,SAAS,kBAAkB;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AACF,GAIG;AAlFH;AAmFE,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAAS,KAAK;AAExD,QAAM,UAAU,MAAM;AACpB,kBAAc,KAAK;AAAA,EACrB,GAAG,CAAC,KAAK,CAAC;AAEV,QAAM,SAAS,MAAM,KAAK,EAAE,SAAS;AACrC,QAAM,aAAa,MAAM,YAAY,MAAM;AACzC,mDAAgB,SAAS,IAAI,WAAW,KAAK;AAAA,EAC/C,GAAG,CAAC,SAAS,IAAI,YAAY,aAAa,CAAC;AAE3C,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAc,CAAC,SAAS;AACtB,YAAI,CAAC,MAAM;AACT,wBAAc,KAAK;AAAA,QACrB;AAAA,MACF;AAAA,MAEA;AAAA;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,UAAU;AAAA,YACZ;AAAA,YAEA;AAAA;AAAA,gBAAC,SAAS;AAAA,gBAAT;AAAA,kBACC,WAAW;AAAA,oBACT;AAAA,oBACA,UAAU;AAAA,kBACZ;AAAA;AAAA,cACF;AAAA,cACC,SAAS;AAAA,cACT,SAAS,oBAAC,SAAM,WAAU,mBAAkB,IAAK;AAAA;AAAA;AAAA,QACpD;AAAA,QACA,oBAAC,0BAAuB,WAAU,YAChC,+BAAC,SAAI,WAAU,aACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,cAAY,SAAS;AAAA,cACrB,WAAU;AAAA,cACV,cACE,cAAS,qBAAT,YACA,SAAS,SAAS,MAAM,YAAY,CAAC;AAAA,cAEvC,OAAO;AAAA,cACP,UAAU,CAAC,UAAU,cAAc,MAAM,OAAO,KAAK;AAAA,cACrD,SAAS,CAAC,UAAU,MAAM,gBAAgB;AAAA,cAC1C,WAAW,CAAC,UAAU;AACpB,sBAAM,gBAAgB;AACtB,oBAAI,MAAM,QAAQ,SAAS;AACzB,wBAAM,eAAe;AACrB,6BAAW;AAAA,gBACb;AAAA,cACF;AAAA;AAAA,UACF;AAAA,UACA,qBAAC,SAAI,WAAU,uCACZ;AAAA,qBACC;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,SAAQ;AAAA,gBACR,MAAK;AAAA,gBACL,WAAU;AAAA,gBACV,SAAS,CAAC,UAAU;AAClB,wBAAM,eAAe;AACrB,wBAAM,gBAAgB;AACtB,gCAAc,EAAE;AAChB,iEAAgB,SAAS,IAAI;AAAA,gBAC/B;AAAA,gBACD;AAAA;AAAA,YAED,IACE;AAAA,YACJ;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,MAAK;AAAA,gBACL,WAAU;AAAA,gBACV,SAAS,CAAC,UAAU;AAClB,wBAAM,eAAe;AACrB,wBAAM,gBAAgB;AACtB,6BAAW;AAAA,gBACb;AAAA,gBACD;AAAA;AAAA,YAED;AAAA,aACF;AAAA,WACF,GACF;AAAA;AAAA;AAAA,EACF;AAEJ;AA6BO,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,wBAAwB;AAAA,EACxB;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,kBAAkB,CAAC;AAAA,EACnB,mBAAmB,CAAC;AAAA,EACpB;AAAA,EACA,wBAAwB;AAAA,EACxB,cAAc,CAAC;AAAA,EACf;AACF,GAAyB;AACvB,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAS,EAAE;AAC3C,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAAiC,CAAC,CAAC;AAC7E,QAAM,CAAC,sBAAsB,uBAAuB,IAAI,MAAM,SAAS,KAAK;AAC5E,QAAM,sBAAsB,gBAAgB,SAAS;AAErD,QAAM,oBAAoB,MAAM,QAAQ,MAAM;AAC5C,UAAM,aAAa,MAAM,KAAK,EAAE,YAAY;AAC5C,QAAI,CAAC,YAAY;AACf,aAAO;AAAA,IACT;AAEA,WAAO,WAAW,OAAO,CAAC,aAAa;AACrC,UAAI,SAAS,MAAM,YAAY,EAAE,SAAS,UAAU,GAAG;AACrD,eAAO;AAAA,MACT;AAEA,UAAI,qBAAqB,QAAQ,GAAG;AAClC,eAAO;AAAA,MACT;AAEA,aAAO,SAAS,QAAQ;AAAA,QAAK,CAAC,WAC5B,eAAe,MAAM,EAAE,YAAY,EAAE,SAAS,UAAU;AAAA,MAC1D;AAAA,IACF,CAAC;AAAA,EACH,GAAG,CAAC,YAAY,KAAK,CAAC;AAGtB,QAAM,iBAAiB,MAAM;AAAA,IAC3B,CAAC,YAAoB,UAA2B;AApPpD;AAqPM,cAAO,0DAAgB,gBAAhB,mBAA6B,SAAS,WAAtC,YAAgD;AAAA,IACzD;AAAA,IACA,CAAC,aAAa;AAAA,EAChB;AAEA,QAAM,cAAc,MAAM,QAAQ,MAAM;AACtC,UAAM,YAAY,OAAO,OAAO,eAAe,EAAE;AAAA,MAC/C,CAAC,OAAO,aAAa,QAAQ,SAAS;AAAA,MACtC;AAAA,IACF;AAEA,UAAM,YAAY,WAAW,OAAO,CAAC,OAAO,aAAa;AAhQ7D;AAiQM,UAAI,CAAC,qBAAqB,QAAQ,GAAG;AACnC,eAAO;AAAA,MACT;AAEA,eAAO,iBAAY,SAAS,EAAE,MAAvB,mBAA0B,UAAS,QAAQ,IAAI;AAAA,IACxD,GAAG,CAAC;AAEJ,WAAO,YAAY,iBAAiB,SAAS;AAAA,EAC/C,GAAG,CAAC,YAAY,iBAAiB,iBAAiB,QAAQ,WAAW,CAAC;AAGtE,QAAM,cAAc,MAAM,QAAQ,MAAM;AA5Q1C;AA6QI,QAAI,CAAC,cAAe,QAAO,CAAC;AAE5B,UAAM,QAAiF,CAAC;AAExF,eAAW,CAAC,YAAY,MAAM,KAAK,OAAO,QAAQ,aAAa,GAAG;AAChE,YAAM,WAAW,WAAW,KAAK,CAAC,MAAM,EAAE,OAAO,UAAU;AAC3D,iBAAW,SAAS,QAAQ;AAC1B,cAAM,SAAS,YAAY,CAAC,qBAAqB,QAAQ,IACrD,SAAS,QAAQ,KAAK,CAAC,QAAQ,eAAe,GAAG,MAAM,KAAK,IAC5D;AACJ,cAAM,QAAQ,SAAS,eAAe,MAAM,IAAI;AAChD,cAAM,UAAS,2BAAgB,UAAU,MAA1B,mBAA6B,SAAS,WAAtC,YAAgD;AAC/D,cAAM,KAAK,EAAE,YAAY,OAAO,OAAO,OAAO,CAAC;AAAA,MACjD;AAAA,IACF;AAEA,WAAO;AAAA,EACT,GAAG,CAAC,eAAe,YAAY,eAAe,CAAC;AAE/C,QAAM,gBACJ,qBAAC,gBACC;AAAA,wBAAC,uBAAoB,SAAO,MAC1B;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,WAAW;AAAA,UACT;AAAA,UACA;AAAA,QACF;AAAA,QAEA;AAAA,8BAAC,cAAW,WAAU,eAAc;AAAA,UAAE;AAAA,UAErC,cAAc,IACb,oBAAC,UAAK,WAAU,0EACb,uBACH,IACE;AAAA;AAAA;AAAA,IACN,GACF;AAAA,IACA,qBAAC,uBAAoB,OAAM,SAAQ,WAAU,iBAC3C;AAAA,0BAAC,SAAI,WAAU,2DACb,+BAAC,SAAI,WAAU,YACb;AAAA,4BAAC,UAAO,WAAU,8EAA6E;AAAA,QAC/F;AAAA,UAAC;AAAA;AAAA,YACC,WAAU;AAAA,YACV,aAAY;AAAA,YACZ,OAAO;AAAA,YACP,UAAU,CAAC,UAAU,SAAS,MAAM,OAAO,KAAK;AAAA,YAChD,SAAS,CAAC,UAAU,MAAM,gBAAgB;AAAA,YAC1C,WAAW,CAAC,UAAU,MAAM,gBAAgB;AAAA;AAAA,QAC9C;AAAA,SACF,GACF;AAAA,MAEA,qBAAC,SAAI,WAAU,qCACZ;AAAA,0BAAkB,IAAI,CAAC,aAAa;AApU/C;AAqUY,gBAAM,cAAa,cAAS,SAAT,YAAiB;AAGpC,cAAI,eAAe,WAAW;AAC5B,kBAAM,UAAS,2BAAgB,SAAS,EAAE,MAA3B,mBAA8B,SAAS,YAAvC,YAAkD;AACjE,mBACE;AAAA,cAAC;AAAA;AAAA,gBAEC,WAAW;AAAA,kBACT;AAAA,kBACA,UAAU;AAAA,gBACZ;AAAA,gBACA,UAAU,CAAC,UAAU;AACnB,wBAAM,eAAe;AACrB,iCAAe,SAAS,IAAI,MAAM;AAAA,gBACpC;AAAA,gBAEA;AAAA,sCAAC,SAAS,MAAT,EAAc,WAAU,oBAAmB;AAAA,kBAC3C,SAAS;AAAA,kBACT,SAAS,oBAAC,SAAM,WAAU,mBAAkB,IAAK;AAAA;AAAA;AAAA,cAZ7C,SAAS;AAAA,YAahB;AAAA,UAEJ;AAGA,cAAI,qBAAqB,QAAQ,GAAG;AAClC,mBACE;AAAA,cAAC;AAAA;AAAA,gBAEC;AAAA,gBACA,QAAO,iBAAY,SAAS,EAAE,MAAvB,YAA4B;AAAA,gBACnC,eAAe;AAAA;AAAA,cAHV,SAAS;AAAA,YAIhB;AAAA,UAEJ;AAGA,gBAAM,aAAY,gBAAW,SAAS,EAAE,MAAtB,YAA2B,IAAI,KAAK,EAAE,YAAY;AACpE,gBAAM,kBAAkB,WACpB,SAAS,QAAQ;AAAA,YAAO,CAAC,QACvB,eAAe,GAAG,EAAE,YAAY,EAAE,SAAS,QAAQ;AAAA,UACrD,IACA,SAAS;AACb,gBAAM,0BAA0B;AAAA,YAC9B,SAAS;AAAA,YACT,SAAS,QAAQ;AAAA,YACjB;AAAA,UACF;AAEA,iBACE;AAAA,YAAC;AAAA;AAAA,cAEC,cAAc,CAAC,SAAS;AACtB,oBAAI,CAAC,MAAM;AACT,gCAAc,CAAC,SAAS;AACtB,0BAAM,OAAO,mBAAK;AAClB,2BAAO,KAAK,SAAS,EAAE;AACvB,2BAAO;AAAA,kBACT,CAAC;AAAA,gBACH;AAAA,cACF;AAAA,cAEA;AAAA,qCAAC,0BAAuB,WAAU,iCAChC;AAAA,sCAAC,SAAS,MAAT,EAAc,WAAU,0CAAyC;AAAA,kBACjE,SAAS;AAAA,mBACZ;AAAA,gBACA,qBAAC,0BAAuB,WAAU,0CAE/B;AAAA,6CACC,oBAAC,SAAI,WAAU,6DACb,+BAAC,SAAI,WAAU,YACb;AAAA,wCAAC,UAAO,WAAU,0EAAyE;AAAA,oBAC3F;AAAA,sBAAC;AAAA;AAAA,wBACC,WAAU;AAAA,wBACV,aAAY;AAAA,wBACZ,QAAO,gBAAW,SAAS,EAAE,MAAtB,YAA2B;AAAA,wBAClC,UAAU,CAAC,MACT,cAAc,CAAC,SAAU,iCAAK,OAAL,EAAW,CAAC,SAAS,EAAE,GAAG,EAAE,OAAO,MAAM,EAAE;AAAA,wBAEtE,SAAS,CAAC,MAAM,EAAE,gBAAgB;AAAA,wBAClC,WAAW,CAAC,MAAM;AAGhB,gCAAM,UAAU,CAAC,aAAa,WAAW,SAAS,UAAU,KAAK;AACjE,8BAAI,CAAC,QAAQ,SAAS,EAAE,GAAG,GAAG;AAC5B,8BAAE,gBAAgB;AAAA,0BACpB;AAAA,wBACF;AAAA;AAAA,oBACF;AAAA,qBACF,GACF;AAAA,kBAGD,gBAAgB,IAAI,CAAC,WAAW;AAlanD,wBAAAA,KAAAC;AAmaoB,0BAAM,QAAQ,eAAe,MAAM;AACnC,0BAAM,QAAQ,eAAe,MAAM;AACnC,0BAAM,YAAWA,OAAAD,MAAA,gBAAgB,SAAS,EAAE,MAA3B,gBAAAA,IAA8B,SAAS,WAAvC,OAAAC,MAAiD;AAClE,0BAAM,WAAW,eAAe,SAAS,IAAI,KAAK;AAClD,2BACE;AAAA,sBAAC;AAAA;AAAA,wBAEC,WAAU;AAAA,wBACV,UAAU,CAAC,UAAU;AACnB,gCAAM,eAAe;AACrB,yCAAe,SAAS,IAAI,KAAK;AAAA,wBACnC;AAAA,wBAEC;AAAA;AAAA,0BACA,WACC,WACE,oBAAC,UAAK,WAAU,+CACb,uBACH,IACE,eAAe,WACjB,oBAAC,UAAK,WAAU,uCAAsC,IAEtD,oBAAC,UAAK,WAAU,+CAA8C,qBAE9D,IAEA;AAAA;AAAA;AAAA,sBApBC;AAAA,oBAqBP;AAAA,kBAEJ,CAAC;AAAA,kBACA,gBAAgB,WAAW,KAAK,SAAS,QAAQ,SAAS,KACzD,oBAAC,SAAI,WAAU,iDAAgD,wBAE/D;AAAA,mBAEJ;AAAA;AAAA;AAAA,YA9EK,SAAS;AAAA,UA+EhB;AAAA,QAEJ,CAAC;AAAA,QAEA,kBAAkB,WAAW,IAC5B,oBAAC,SAAI,WAAU,iDAAgD,8BAE/D,IACE;AAAA,SACN;AAAA,MAEC,sBACC,oBAAC,SAAI,WAAU,8BACb;AAAA,QAAC,iBAAiB;AAAA,QAAjB;AAAA,UACC,MAAM;AAAA,UACN,cAAc;AAAA,UAEd;AAAA,gCAAC,iBAAiB,SAAjB,EAAyB,SAAO,MAC/B;AAAA,cAAC;AAAA;AAAA,gBACC,WAAU;AAAA,gBACV,UAAU,CAAC,UAAU;AACnB,wBAAM,eAAe;AACrB,0CAAwB,IAAI;AAAA,gBAC9B;AAAA,gBAEA;AAAA,sCAAC,QAAK,WAAU,0CAAyC;AAAA,kBACxD;AAAA,kBACA,iBAAiB,SAAS,IACzB,oBAAC,UAAK,WAAU,kEACb,2BAAiB,QACpB,IACE;AAAA;AAAA;AAAA,YACN,GACF;AAAA,YACA,oBAAC,iBAAiB,QAAjB,EACC;AAAA,cAAC,iBAAiB;AAAA,cAAjB;AAAA,gBACC,OAAM;AAAA,gBACN,MAAK;AAAA,gBACL,YAAY;AAAA,gBACZ,WAAU;AAAA,gBACV,iBAAiB,MAAM,wBAAwB,KAAK;AAAA,gBACpD,mBAAmB,CAAC,UAAU;AAC5B,wBAAM,SAAS,MAAM;AACrB,sBAAI,iCAAQ,QAAQ,wCAAwC;AAC1D,0BAAM,eAAe;AAAA,kBACvB;AAAA,gBACF;AAAA,gBAEA;AAAA,kBAAC;AAAA;AAAA,oBACC,QAAQ;AAAA,oBACR,YAAY;AAAA,oBACZ,oBAAoB,+DAA6B,MAAM;AAAA,oBAAC;AAAA;AAAA,gBAC1D;AAAA;AAAA,YACF,GACF;AAAA;AAAA;AAAA,MACF,GACF,IACE;AAAA,OACN;AAAA,KACF;AAIF,MAAI,YAAY,SAAS,GAAG;AAC1B,WACE,qBAAC,SAAI,WAAU,uCACZ;AAAA;AAAA,MACA,YAAY,IAAI,CAAC,SAChB;AAAA,QAAC;AAAA;AAAA,UAEC,MAAK;AAAA,UACL,SAAS,MAAM,iDAAiB,KAAK,YAAY,KAAK;AAAA,UACtD,WAAW;AAAA,YACT;AAAA,YACA,KAAK,SACD,gFACA;AAAA,UACN;AAAA,UAEA;AAAA,iCAAC,UAAK,WAAU,oCACb;AAAA;AAAA,cAAY;AAAA,cAAE;AAAA,eACjB;AAAA,YACC,KAAK;AAAA;AAAA;AAAA,QAbD,GAAG,KAAK,UAAU,IAAI,KAAK,KAAK;AAAA,MAcvC,CACD;AAAA,OACH;AAAA,EAEJ;AAEA,SAAO;AACT;","names":["_a","_b"]}
@@ -24,7 +24,6 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
24
24
  import * as React from "react";
25
25
  import { Popover as PopoverPrimitive } from "radix-ui";
26
26
  import {
27
- Check,
28
27
  ChevronDown,
29
28
  CornerDownLeft,
30
29
  Plus,
@@ -50,16 +49,21 @@ function RecipientChipPill({
50
49
  onConfirm,
51
50
  onRemove
52
51
  }) {
53
- const display = recipient.name || recipient.email;
52
+ const primary = recipient.name || recipient.email;
53
+ const secondary = recipient.name ? recipient.email : "";
54
+ const display = primary || recipient.email;
54
55
  if (!recipient.confirmed) {
55
- return /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-amber-300 bg-amber-50 text-amber-900", children: [
56
- /* @__PURE__ */ jsx("span", { className: "truncate max-w-[180px]", children: display }),
56
+ return /* @__PURE__ */ jsxs("span", { className: "inline-flex max-w-full items-center gap-1 rounded text-xs border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800", children: [
57
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 inline-flex items-baseline gap-1", children: [
58
+ /* @__PURE__ */ jsx("span", { className: "truncate font-medium max-w-[150px]", children: primary }),
59
+ secondary ? /* @__PURE__ */ jsx("span", { className: "truncate max-w-[190px] text-amber-800/70", children: secondary }) : null
60
+ ] }),
57
61
  /* @__PURE__ */ jsx(
58
62
  "button",
59
63
  {
60
64
  type: "button",
61
65
  onClick: onConfirm,
62
- className: "text-[10.5px] font-semibold px-[7px] py-0.5 rounded bg-amber-300/50 hover:bg-amber-300/85",
66
+ className: "ml-1 rounded bg-amber-200/60 px-2 py-0.5 text-xs font-semibold text-amber-800 hover:bg-amber-200",
63
67
  children: "Confirm"
64
68
  }
65
69
  ),
@@ -69,22 +73,24 @@ function RecipientChipPill({
69
73
  type: "button",
70
74
  "aria-label": `Remove ${display}`,
71
75
  onClick: onRemove,
72
- className: "inline-flex items-center justify-center size-[17px] rounded text-amber-700/80 hover:bg-amber-300/40",
76
+ className: "inline-flex size-4 items-center justify-center rounded text-amber-700/80 hover:bg-amber-200/70",
73
77
  children: /* @__PURE__ */ jsx(X, { className: "size-3" })
74
78
  }
75
79
  )
76
80
  ] });
77
81
  }
78
- return /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-border bg-muted/50 text-foreground", children: [
79
- /* @__PURE__ */ jsx("span", { className: "inline-flex items-center justify-center size-[17px] rounded bg-emerald-50 text-emerald-700", children: /* @__PURE__ */ jsx(Check, { className: "size-3" }) }),
80
- /* @__PURE__ */ jsx("span", { className: "truncate max-w-[180px]", children: display }),
82
+ return /* @__PURE__ */ jsxs("span", { className: "inline-flex max-w-full items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-xs text-foreground", children: [
83
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 inline-flex items-baseline gap-1", children: [
84
+ /* @__PURE__ */ jsx("span", { className: "truncate font-medium max-w-[150px]", children: primary }),
85
+ secondary ? /* @__PURE__ */ jsx("span", { className: "truncate max-w-[190px] text-muted-foreground", children: secondary }) : null
86
+ ] }),
81
87
  /* @__PURE__ */ jsx(
82
88
  "button",
83
89
  {
84
90
  type: "button",
85
91
  "aria-label": `Remove ${display}`,
86
92
  onClick: onRemove,
87
- className: "inline-flex items-center justify-center size-[17px] rounded text-muted-foreground hover:bg-muted",
93
+ className: "inline-flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
88
94
  children: /* @__PURE__ */ jsx(X, { className: "size-3" })
89
95
  }
90
96
  )
@@ -215,22 +221,31 @@ function EmailRecipientField({
215
221
  const amberRow = state === "amber";
216
222
  const added = addedEmails != null ? addedEmails : /* @__PURE__ */ new Set();
217
223
  const resolvedPlaceholder = placeholder != null ? placeholder : recipients.length > 0 ? "Add another..." : "Add email...";
224
+ const committedByKeyRef = React.useRef(null);
218
225
  function addEmail(email) {
219
226
  const trimmed = email.trim();
220
- if (!isValidEmail(trimmed)) return;
221
- if (added.has(trimmed.toLowerCase())) return;
227
+ if (!isValidEmail(trimmed)) return false;
228
+ if (added.has(trimmed.toLowerCase())) return false;
222
229
  onRecipientsChange([
223
230
  ...recipients,
224
231
  { id: trimmed, email: trimmed, name: "", confirmed: false }
225
232
  ]);
226
233
  setValue("");
234
+ return true;
227
235
  }
228
236
  function handleKeyDown(event) {
229
- if ((event.key === "Enter" || event.key === ",") && isValidEmail(value)) {
237
+ if ((event.key === "Enter" || event.key === ",") && value.trim()) {
230
238
  event.preventDefault();
231
239
  addEmail(value);
232
240
  return;
233
241
  }
242
+ if (event.key === "Tab" && value.trim()) {
243
+ const trimmed = value.trim();
244
+ if (addEmail(trimmed)) {
245
+ committedByKeyRef.current = trimmed.toLowerCase();
246
+ }
247
+ return;
248
+ }
234
249
  if (event.key === "Backspace" && value === "" && recipients.length > 0) {
235
250
  event.preventDefault();
236
251
  onRecipientsChange(recipients.slice(0, -1));
@@ -291,7 +306,12 @@ function EmailRecipientField({
291
306
  onChange: (event) => setValue(event.target.value),
292
307
  onKeyDown: handleKeyDown,
293
308
  onBlur: () => {
294
- if (isValidEmail(value)) addEmail(value);
309
+ const trimmed = value.trim().toLowerCase();
310
+ if (trimmed && committedByKeyRef.current === trimmed) {
311
+ committedByKeyRef.current = null;
312
+ return;
313
+ }
314
+ addEmail(value);
295
315
  },
296
316
  placeholder: resolvedPlaceholder,
297
317
  className: "min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground"
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/email-recipient-field.tsx"],"sourcesContent":["\"use client\"\n\nimport * as React from \"react\"\nimport { Popover as PopoverPrimitive } from \"radix-ui\"\nimport {\n Check,\n ChevronDown,\n CornerDownLeft,\n Plus,\n Search,\n Users,\n X,\n} from \"lucide-react\"\n\nimport { cn } from \"../lib/utils\"\nimport type { SuggestedContact } from \"./suggested-actions\"\n\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nfunction isValidEmail(value: string): boolean {\n return EMAIL_REGEX.test(value.trim())\n}\n\nfunction contactEmail(contact: SuggestedContact): string | undefined {\n return contact.email ?? contact.emails?.[0]\n}\n\nfunction getInitials(name: string, fallback: string): string {\n const source = name?.trim() || fallback\n return source\n .split(/[\\s@.]+/)\n .map((part) => part[0])\n .filter(Boolean)\n .slice(0, 2)\n .join(\"\")\n .toUpperCase()\n}\n\nexport interface RecipientChip {\n id: string\n email: string\n name: string\n confirmed: boolean\n}\n\nexport interface EmailRecipientFieldProps {\n label: string\n recipients: RecipientChip[]\n onRecipientsChange: (recipients: RecipientChip[]) => void\n amber?: boolean\n contacts?: SuggestedContact[]\n showPicker?: boolean\n showCcBcc?: boolean\n ccBccOpen?: boolean\n onCcBccToggle?: () => void\n addedEmails?: Set<string>\n placeholder?: string\n contactToRecipient?: (contact: SuggestedContact) => RecipientChip\n /**\n * Async search mode. When provided, the picker forwards the typed query to\n * this callback (the caller debounces + fetches) and treats `contacts` as the\n * already server-filtered result set instead of filtering it client-side.\n */\n onSearch?: (query: string) => void\n /** Shows a \"Searching contacts...\" indicator while async results load. */\n searchLoading?: boolean\n}\n\nfunction RecipientChipPill({\n recipient,\n onConfirm,\n onRemove,\n}: {\n recipient: RecipientChip\n onConfirm: () => void\n onRemove: () => void\n}) {\n const display = recipient.name || recipient.email\n\n if (!recipient.confirmed) {\n return (\n <span className=\"inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-amber-300 bg-amber-50 text-amber-900\">\n <span className=\"truncate max-w-[180px]\">{display}</span>\n <button\n type=\"button\"\n onClick={onConfirm}\n className=\"text-[10.5px] font-semibold px-[7px] py-0.5 rounded bg-amber-300/50 hover:bg-amber-300/85\"\n >\n Confirm\n </button>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex items-center justify-center size-[17px] rounded text-amber-700/80 hover:bg-amber-300/40\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n }\n\n return (\n <span className=\"inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-border bg-muted/50 text-foreground\">\n <span className=\"inline-flex items-center justify-center size-[17px] rounded bg-emerald-50 text-emerald-700\">\n <Check className=\"size-3\" />\n </span>\n <span className=\"truncate max-w-[180px]\">{display}</span>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex items-center justify-center size-[17px] rounded text-muted-foreground hover:bg-muted\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n}\n\n// Contents of the contact picker dropdown. Rendered inside a Radix\n// `Popover.Content` so its focus scope pushes onto the focus-scope stack and\n// PAUSES any parent modal's scope (e.g. the quick-action Dialog). This is what\n// makes the search input typeable: a plain `createPortal(..., document.body)`\n// element renders outside the Dialog's `DialogContent`, so the Dialog's\n// FocusScope kept yanking focus back (input un-typeable) and its modal\n// `pointer-events: none` on <body> left the portal click-dead. A stacked Radix\n// Popover layer gets `pointer-events: auto` and its own (paused-parent) focus\n// scope, fixing both. See WIT-800 / WIT-770.\nfunction ContactPickerContents({\n contacts,\n addedEmails,\n onSelect,\n onAddEmail,\n onSearch,\n searchLoading = false,\n}: {\n contacts: SuggestedContact[]\n addedEmails: Set<string>\n onSelect: (contact: SuggestedContact) => void\n onAddEmail: (email: string) => void\n onSearch?: (query: string) => void\n searchLoading?: boolean\n}) {\n const [query, setQuery] = React.useState(\"\")\n\n const asyncMode = typeof onSearch === \"function\"\n\n // Keep the latest onSearch in a ref so the search effect below fires only when\n // the query (or async mode) changes, not when the callback identity changes.\n // This lets callers pass an inline handler without triggering duplicate\n // fetches on unrelated parent re-renders.\n const onSearchRef = React.useRef(onSearch)\n React.useEffect(() => {\n onSearchRef.current = onSearch\n }, [onSearch])\n\n // Async mode: forward the query upward (caller debounces + fetches) and treat\n // `contacts` as the already server-filtered result set. Local mode: filter the\n // static `contacts` array client-side.\n React.useEffect(() => {\n if (asyncMode) {\n onSearchRef.current?.(query)\n }\n }, [asyncMode, query])\n\n const normalizedQuery = query.trim().toLowerCase()\n const filtered = asyncMode\n ? contacts\n : normalizedQuery\n ? contacts.filter((contact) => {\n const email = contactEmail(contact) ?? \"\"\n return (\n contact.name.toLowerCase().includes(normalizedQuery) ||\n contact.role.toLowerCase().includes(normalizedQuery) ||\n email.toLowerCase().includes(normalizedQuery)\n )\n })\n : contacts\n\n const queryIsEmail = isValidEmail(query)\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if (event.key === \"Enter\" && queryIsEmail) {\n event.preventDefault()\n onAddEmail(query.trim())\n setQuery(\"\")\n }\n }\n\n return (\n <>\n <div className=\"flex items-center gap-2 px-3 py-2.5 border-b border-border/50\">\n <Search className=\"size-4 text-muted-foreground shrink-0\" />\n <input\n autoFocus\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onKeyDown={handleKeyDown}\n className=\"flex-1 text-[13px] bg-transparent outline-none\"\n placeholder=\"Search contacts...\"\n />\n </div>\n\n <div role=\"listbox\" className=\"max-h-[208px] overflow-y-auto p-1\">\n {searchLoading ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n Searching contacts...\n </div>\n ) : asyncMode && normalizedQuery.length === 0 ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n Type a name or email to search contacts.\n </div>\n ) : !asyncMode && contacts.length === 0 ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n <div className=\"font-medium text-foreground/80\">\n No contacts for this account\n </div>\n <div className=\"mt-1\">\n Type an email address above and press Enter to add a recipient.\n </div>\n </div>\n ) : filtered.length === 0 ? (\n <div className=\"px-3 py-4 text-center text-[13px] text-muted-foreground\">\n <div>No contact matches &lsquo;{query}&rsquo;.</div>\n {queryIsEmail ? (\n <div className=\"mt-1\">Press Enter to add {query}.</div>\n ) : null}\n </div>\n ) : (\n filtered.map((contact, index) => {\n const email = contactEmail(contact)\n const noEmail = !email || !isValidEmail(email)\n const alreadyAdded = email\n ? addedEmails.has(email.toLowerCase())\n : false\n const disabled = noEmail || alreadyAdded\n\n return (\n <div\n key={`${contact.name}-${email ?? index}`}\n role=\"option\"\n aria-selected={false}\n aria-disabled={disabled}\n onClick={() => {\n if (!disabled) onSelect(contact)\n }}\n className={cn(\n \"flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60\",\n disabled && \"opacity-45 pointer-events-none\",\n )}\n >\n <div className=\"flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-muted text-[11px] font-medium text-muted-foreground\">\n {getInitials(contact.name, email ?? \"?\")}\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex items-center gap-1.5\">\n <span className=\"truncate text-[13px] font-medium text-foreground\">\n {contact.name}\n </span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n {contact.role}\n </span>\n </div>\n {email ? (\n <div className=\"truncate text-[11px] text-muted-foreground\">\n {email}\n </div>\n ) : null}\n </div>\n {alreadyAdded ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n Added\n </span>\n ) : noEmail ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n No email\n </span>\n ) : null}\n </div>\n )\n })\n )}\n </div>\n\n <div className=\"flex items-center gap-1.5 px-3 py-2 border-t border-border/50 text-[11px] text-muted-foreground\">\n <CornerDownLeft className=\"size-3 shrink-0\" />\n <span>Type an address and press Enter to add someone not listed.</span>\n </div>\n </>\n )\n}\n\nexport function EmailRecipientField({\n label,\n recipients,\n onRecipientsChange,\n amber = false,\n contacts = [],\n showPicker = false,\n showCcBcc = false,\n ccBccOpen = false,\n onCcBccToggle,\n addedEmails,\n placeholder,\n contactToRecipient,\n onSearch,\n searchLoading,\n}: EmailRecipientFieldProps) {\n const [value, setValue] = React.useState(\"\")\n const [pickerOpen, setPickerOpen] = React.useState(false)\n\n const hasUnconfirmed = recipients.some((r) => !r.confirmed)\n const state: \"default\" | \"amber\" =\n amber && hasUnconfirmed ? \"amber\" : \"default\"\n const amberRow = state === \"amber\"\n\n const added = addedEmails ?? new Set<string>()\n\n const resolvedPlaceholder =\n placeholder ?? (recipients.length > 0 ? \"Add another...\" : \"Add email...\")\n\n function addEmail(email: string) {\n const trimmed = email.trim()\n if (!isValidEmail(trimmed)) return\n if (added.has(trimmed.toLowerCase())) return\n onRecipientsChange([\n ...recipients,\n { id: trimmed, email: trimmed, name: \"\", confirmed: false },\n ])\n setValue(\"\")\n }\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if ((event.key === \"Enter\" || event.key === \",\") && isValidEmail(value)) {\n event.preventDefault()\n addEmail(value)\n return\n }\n if (event.key === \"Backspace\" && value === \"\" && recipients.length > 0) {\n event.preventDefault()\n onRecipientsChange(recipients.slice(0, -1))\n }\n }\n\n function confirmRecipient(id: string) {\n onRecipientsChange(\n recipients.map((r) => (r.id === id ? { ...r, confirmed: true } : r)),\n )\n }\n\n function removeRecipient(id: string) {\n onRecipientsChange(recipients.filter((r) => r.id !== id))\n }\n\n function selectContact(contact: SuggestedContact) {\n const recipient =\n contactToRecipient?.(contact) ??\n ({\n id: contactEmail(contact) ?? contact.name,\n email: contactEmail(contact) ?? \"\",\n name: contact.name,\n confirmed: true,\n } satisfies RecipientChip)\n onRecipientsChange([...recipients, recipient])\n setPickerOpen(false)\n }\n\n return (\n <div\n className={cn(\n \"grid grid-cols-[60px_1fr] gap-2 px-[18px] py-[9px] border-b border-border/70 items-start text-sm\",\n amberRow && \"bg-amber-50/35 border-amber-200/80\",\n )}\n >\n <div\n className={cn(\n \"text-[11px] font-semibold uppercase tracking-wide text-muted-foreground pt-[7px]\",\n amberRow && \"text-amber-700\",\n )}\n >\n {label}\n </div>\n\n <div className=\"min-w-0\">\n <div className=\"flex flex-wrap gap-1.5 items-center\">\n {recipients.map((recipient) => (\n <RecipientChipPill\n key={recipient.id}\n recipient={recipient}\n onConfirm={() => confirmRecipient(recipient.id)}\n onRemove={() => removeRecipient(recipient.id)}\n />\n ))}\n <input\n value={value}\n onChange={(event) => setValue(event.target.value)}\n onKeyDown={handleKeyDown}\n onBlur={() => {\n // Commit any valid pending email so it is not silently dropped\n // when the user clicks Send without pressing Enter/comma first.\n if (isValidEmail(value)) addEmail(value)\n }}\n placeholder={resolvedPlaceholder}\n className=\"min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground\"\n />\n </div>\n\n {showPicker || showCcBcc ? (\n <div className=\"flex gap-1.5 mt-2\">\n {showPicker ? (\n <PopoverPrimitive.Root open={pickerOpen} onOpenChange={setPickerOpen}>\n <PopoverPrimitive.Trigger asChild>\n <button\n type=\"button\"\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Users className=\"size-3\" />\n Contacts\n <ChevronDown className=\"size-3\" />\n </button>\n </PopoverPrimitive.Trigger>\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n side=\"bottom\"\n align=\"start\"\n sideOffset={4}\n collisionPadding={16}\n className=\"z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0\"\n >\n <ContactPickerContents\n contacts={contacts}\n addedEmails={added}\n onSelect={selectContact}\n onAddEmail={(email) => {\n addEmail(email)\n setPickerOpen(false)\n }}\n onSearch={onSearch}\n searchLoading={searchLoading}\n />\n </PopoverPrimitive.Content>\n </PopoverPrimitive.Portal>\n </PopoverPrimitive.Root>\n ) : null}\n {showCcBcc ? (\n <button\n type=\"button\"\n onClick={onCcBccToggle}\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Plus className=\"size-3\" />\n {ccBccOpen ? \"Hide Cc/Bcc\" : \"Add Cc/Bcc\"}\n </button>\n ) : null}\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/email-recipient-field.tsx"],"sourcesContent":["\"use client\"\n\nimport * as React from \"react\"\nimport { Popover as PopoverPrimitive } from \"radix-ui\"\nimport {\n ChevronDown,\n CornerDownLeft,\n Plus,\n Search,\n Users,\n X,\n} from \"lucide-react\"\n\nimport { cn } from \"../lib/utils\"\nimport type { SuggestedContact } from \"./suggested-actions\"\n\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nfunction isValidEmail(value: string): boolean {\n return EMAIL_REGEX.test(value.trim())\n}\n\nfunction contactEmail(contact: SuggestedContact): string | undefined {\n return contact.email ?? contact.emails?.[0]\n}\n\nfunction getInitials(name: string, fallback: string): string {\n const source = name?.trim() || fallback\n return source\n .split(/[\\s@.]+/)\n .map((part) => part[0])\n .filter(Boolean)\n .slice(0, 2)\n .join(\"\")\n .toUpperCase()\n}\n\nexport interface RecipientChip {\n id: string\n email: string\n name: string\n confirmed: boolean\n}\n\nexport interface EmailRecipientFieldProps {\n label: string\n recipients: RecipientChip[]\n onRecipientsChange: (recipients: RecipientChip[]) => void\n amber?: boolean\n contacts?: SuggestedContact[]\n showPicker?: boolean\n showCcBcc?: boolean\n ccBccOpen?: boolean\n onCcBccToggle?: () => void\n addedEmails?: Set<string>\n placeholder?: string\n contactToRecipient?: (contact: SuggestedContact) => RecipientChip\n /**\n * Async search mode. When provided, the picker forwards the typed query to\n * this callback (the caller debounces + fetches) and treats `contacts` as the\n * already server-filtered result set instead of filtering it client-side.\n */\n onSearch?: (query: string) => void\n /** Shows a \"Searching contacts...\" indicator while async results load. */\n searchLoading?: boolean\n}\n\nfunction RecipientChipPill({\n recipient,\n onConfirm,\n onRemove,\n}: {\n recipient: RecipientChip\n onConfirm: () => void\n onRemove: () => void\n}) {\n const primary = recipient.name || recipient.email\n const secondary = recipient.name ? recipient.email : \"\"\n const display = primary || recipient.email\n\n if (!recipient.confirmed) {\n return (\n <span className=\"inline-flex max-w-full items-center gap-1 rounded text-xs border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800\">\n <span className=\"min-w-0 inline-flex items-baseline gap-1\">\n <span className=\"truncate font-medium max-w-[150px]\">{primary}</span>\n {secondary ? (\n <span className=\"truncate max-w-[190px] text-amber-800/70\">\n {secondary}\n </span>\n ) : null}\n </span>\n <button\n type=\"button\"\n onClick={onConfirm}\n className=\"ml-1 rounded bg-amber-200/60 px-2 py-0.5 text-xs font-semibold text-amber-800 hover:bg-amber-200\"\n >\n Confirm\n </button>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex size-4 items-center justify-center rounded text-amber-700/80 hover:bg-amber-200/70\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n }\n\n return (\n <span className=\"inline-flex max-w-full items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-xs text-foreground\">\n <span className=\"min-w-0 inline-flex items-baseline gap-1\">\n <span className=\"truncate font-medium max-w-[150px]\">{primary}</span>\n {secondary ? (\n <span className=\"truncate max-w-[190px] text-muted-foreground\">\n {secondary}\n </span>\n ) : null}\n </span>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n}\n\n// Contents of the contact picker dropdown. Rendered inside a Radix\n// `Popover.Content` so its focus scope pushes onto the focus-scope stack and\n// PAUSES any parent modal's scope (e.g. the quick-action Dialog). This is what\n// makes the search input typeable: a plain `createPortal(..., document.body)`\n// element renders outside the Dialog's `DialogContent`, so the Dialog's\n// FocusScope kept yanking focus back (input un-typeable) and its modal\n// `pointer-events: none` on <body> left the portal click-dead. A stacked Radix\n// Popover layer gets `pointer-events: auto` and its own (paused-parent) focus\n// scope, fixing both. See WIT-800 / WIT-770.\nfunction ContactPickerContents({\n contacts,\n addedEmails,\n onSelect,\n onAddEmail,\n onSearch,\n searchLoading = false,\n}: {\n contacts: SuggestedContact[]\n addedEmails: Set<string>\n onSelect: (contact: SuggestedContact) => void\n onAddEmail: (email: string) => void\n onSearch?: (query: string) => void\n searchLoading?: boolean\n}) {\n const [query, setQuery] = React.useState(\"\")\n\n const asyncMode = typeof onSearch === \"function\"\n\n // Keep the latest onSearch in a ref so the search effect below fires only when\n // the query (or async mode) changes, not when the callback identity changes.\n // This lets callers pass an inline handler without triggering duplicate\n // fetches on unrelated parent re-renders.\n const onSearchRef = React.useRef(onSearch)\n React.useEffect(() => {\n onSearchRef.current = onSearch\n }, [onSearch])\n\n // Async mode: forward the query upward (caller debounces + fetches) and treat\n // `contacts` as the already server-filtered result set. Local mode: filter the\n // static `contacts` array client-side.\n React.useEffect(() => {\n if (asyncMode) {\n onSearchRef.current?.(query)\n }\n }, [asyncMode, query])\n\n const normalizedQuery = query.trim().toLowerCase()\n const filtered = asyncMode\n ? contacts\n : normalizedQuery\n ? contacts.filter((contact) => {\n const email = contactEmail(contact) ?? \"\"\n return (\n contact.name.toLowerCase().includes(normalizedQuery) ||\n contact.role.toLowerCase().includes(normalizedQuery) ||\n email.toLowerCase().includes(normalizedQuery)\n )\n })\n : contacts\n\n const queryIsEmail = isValidEmail(query)\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if (event.key === \"Enter\" && queryIsEmail) {\n event.preventDefault()\n onAddEmail(query.trim())\n setQuery(\"\")\n }\n }\n\n return (\n <>\n <div className=\"flex items-center gap-2 px-3 py-2.5 border-b border-border/50\">\n <Search className=\"size-4 text-muted-foreground shrink-0\" />\n <input\n autoFocus\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onKeyDown={handleKeyDown}\n className=\"flex-1 text-[13px] bg-transparent outline-none\"\n placeholder=\"Search contacts...\"\n />\n </div>\n\n <div role=\"listbox\" className=\"max-h-[208px] overflow-y-auto p-1\">\n {searchLoading ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n Searching contacts...\n </div>\n ) : asyncMode && normalizedQuery.length === 0 ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n Type a name or email to search contacts.\n </div>\n ) : !asyncMode && contacts.length === 0 ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n <div className=\"font-medium text-foreground/80\">\n No contacts for this account\n </div>\n <div className=\"mt-1\">\n Type an email address above and press Enter to add a recipient.\n </div>\n </div>\n ) : filtered.length === 0 ? (\n <div className=\"px-3 py-4 text-center text-[13px] text-muted-foreground\">\n <div>No contact matches &lsquo;{query}&rsquo;.</div>\n {queryIsEmail ? (\n <div className=\"mt-1\">Press Enter to add {query}.</div>\n ) : null}\n </div>\n ) : (\n filtered.map((contact, index) => {\n const email = contactEmail(contact)\n const noEmail = !email || !isValidEmail(email)\n const alreadyAdded = email\n ? addedEmails.has(email.toLowerCase())\n : false\n const disabled = noEmail || alreadyAdded\n\n return (\n <div\n key={`${contact.name}-${email ?? index}`}\n role=\"option\"\n aria-selected={false}\n aria-disabled={disabled}\n onClick={() => {\n if (!disabled) onSelect(contact)\n }}\n className={cn(\n \"flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60\",\n disabled && \"opacity-45 pointer-events-none\",\n )}\n >\n <div className=\"flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-muted text-[11px] font-medium text-muted-foreground\">\n {getInitials(contact.name, email ?? \"?\")}\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex items-center gap-1.5\">\n <span className=\"truncate text-[13px] font-medium text-foreground\">\n {contact.name}\n </span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n {contact.role}\n </span>\n </div>\n {email ? (\n <div className=\"truncate text-[11px] text-muted-foreground\">\n {email}\n </div>\n ) : null}\n </div>\n {alreadyAdded ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n Added\n </span>\n ) : noEmail ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n No email\n </span>\n ) : null}\n </div>\n )\n })\n )}\n </div>\n\n <div className=\"flex items-center gap-1.5 px-3 py-2 border-t border-border/50 text-[11px] text-muted-foreground\">\n <CornerDownLeft className=\"size-3 shrink-0\" />\n <span>Type an address and press Enter to add someone not listed.</span>\n </div>\n </>\n )\n}\n\nexport function EmailRecipientField({\n label,\n recipients,\n onRecipientsChange,\n amber = false,\n contacts = [],\n showPicker = false,\n showCcBcc = false,\n ccBccOpen = false,\n onCcBccToggle,\n addedEmails,\n placeholder,\n contactToRecipient,\n onSearch,\n searchLoading,\n}: EmailRecipientFieldProps) {\n const [value, setValue] = React.useState(\"\")\n const [pickerOpen, setPickerOpen] = React.useState(false)\n\n const hasUnconfirmed = recipients.some((r) => !r.confirmed)\n const state: \"default\" | \"amber\" =\n amber && hasUnconfirmed ? \"amber\" : \"default\"\n const amberRow = state === \"amber\"\n\n const added = addedEmails ?? new Set<string>()\n\n const resolvedPlaceholder =\n placeholder ?? (recipients.length > 0 ? \"Add another...\" : \"Add email...\")\n\n const committedByKeyRef = React.useRef<string | null>(null)\n\n function addEmail(email: string): boolean {\n const trimmed = email.trim()\n if (!isValidEmail(trimmed)) return false\n if (added.has(trimmed.toLowerCase())) return false\n onRecipientsChange([\n ...recipients,\n { id: trimmed, email: trimmed, name: \"\", confirmed: false },\n ])\n setValue(\"\")\n return true\n }\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if ((event.key === \"Enter\" || event.key === \",\") && value.trim()) {\n event.preventDefault()\n addEmail(value)\n return\n }\n if (event.key === \"Tab\" && value.trim()) {\n const trimmed = value.trim()\n if (addEmail(trimmed)) {\n committedByKeyRef.current = trimmed.toLowerCase()\n }\n return\n }\n if (event.key === \"Backspace\" && value === \"\" && recipients.length > 0) {\n event.preventDefault()\n onRecipientsChange(recipients.slice(0, -1))\n }\n }\n\n function confirmRecipient(id: string) {\n onRecipientsChange(\n recipients.map((r) => (r.id === id ? { ...r, confirmed: true } : r)),\n )\n }\n\n function removeRecipient(id: string) {\n onRecipientsChange(recipients.filter((r) => r.id !== id))\n }\n\n function selectContact(contact: SuggestedContact) {\n const recipient =\n contactToRecipient?.(contact) ??\n ({\n id: contactEmail(contact) ?? contact.name,\n email: contactEmail(contact) ?? \"\",\n name: contact.name,\n confirmed: true,\n } satisfies RecipientChip)\n onRecipientsChange([...recipients, recipient])\n setPickerOpen(false)\n }\n\n return (\n <div\n className={cn(\n \"grid grid-cols-[60px_1fr] gap-2 px-[18px] py-[9px] border-b border-border/70 items-start text-sm\",\n amberRow && \"bg-amber-50/35 border-amber-200/80\",\n )}\n >\n <div\n className={cn(\n \"text-[11px] font-semibold uppercase tracking-wide text-muted-foreground pt-[7px]\",\n amberRow && \"text-amber-700\",\n )}\n >\n {label}\n </div>\n\n <div className=\"min-w-0\">\n <div className=\"flex flex-wrap gap-1.5 items-center\">\n {recipients.map((recipient) => (\n <RecipientChipPill\n key={recipient.id}\n recipient={recipient}\n onConfirm={() => confirmRecipient(recipient.id)}\n onRemove={() => removeRecipient(recipient.id)}\n />\n ))}\n <input\n value={value}\n onChange={(event) => setValue(event.target.value)}\n onKeyDown={handleKeyDown}\n onBlur={() => {\n const trimmed = value.trim().toLowerCase()\n if (trimmed && committedByKeyRef.current === trimmed) {\n committedByKeyRef.current = null\n return\n }\n // Commit any valid pending email so it is not silently dropped\n // when the user clicks Send without pressing Enter/comma first.\n addEmail(value)\n }}\n placeholder={resolvedPlaceholder}\n className=\"min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground\"\n />\n </div>\n\n {showPicker || showCcBcc ? (\n <div className=\"flex gap-1.5 mt-2\">\n {showPicker ? (\n <PopoverPrimitive.Root open={pickerOpen} onOpenChange={setPickerOpen}>\n <PopoverPrimitive.Trigger asChild>\n <button\n type=\"button\"\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Users className=\"size-3\" />\n Contacts\n <ChevronDown className=\"size-3\" />\n </button>\n </PopoverPrimitive.Trigger>\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n side=\"bottom\"\n align=\"start\"\n sideOffset={4}\n collisionPadding={16}\n className=\"z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0\"\n >\n <ContactPickerContents\n contacts={contacts}\n addedEmails={added}\n onSelect={selectContact}\n onAddEmail={(email) => {\n addEmail(email)\n setPickerOpen(false)\n }}\n onSearch={onSearch}\n searchLoading={searchLoading}\n />\n </PopoverPrimitive.Content>\n </PopoverPrimitive.Portal>\n </PopoverPrimitive.Root>\n ) : null}\n {showCcBcc ? (\n <button\n type=\"button\"\n onClick={onCcBccToggle}\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Plus className=\"size-3\" />\n {ccBccOpen ? \"Hide Cc/Bcc\" : \"Add Cc/Bcc\"}\n </button>\n ) : null}\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAmFQ,SAwHJ,UAvHM,KADF;AAjFR,YAAY,WAAW;AACvB,SAAS,WAAW,wBAAwB;AAC5C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,UAAU;AAGnB,MAAM,cAAc;AAEpB,SAAS,aAAa,OAAwB;AAC5C,SAAO,YAAY,KAAK,MAAM,KAAK,CAAC;AACtC;AAEA,SAAS,aAAa,SAA+C;AAtBrE;AAuBE,UAAO,aAAQ,UAAR,aAAiB,aAAQ,WAAR,mBAAiB;AAC3C;AAEA,SAAS,YAAY,MAAc,UAA0B;AAC3D,QAAM,UAAS,6BAAM,WAAU;AAC/B,SAAO,OACJ,MAAM,SAAS,EACf,IAAI,CAAC,SAAS,KAAK,CAAC,CAAC,EACrB,OAAO,OAAO,EACd,MAAM,GAAG,CAAC,EACV,KAAK,EAAE,EACP,YAAY;AACjB;AAgCA,SAAS,kBAAkB;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,QAAM,UAAU,UAAU,QAAQ,UAAU;AAC5C,QAAM,YAAY,UAAU,OAAO,UAAU,QAAQ;AACrD,QAAM,UAAU,WAAW,UAAU;AAErC,MAAI,CAAC,UAAU,WAAW;AACxB,WACE,qBAAC,UAAK,WAAU,4HACd;AAAA,2BAAC,UAAK,WAAU,4CACd;AAAA,4BAAC,UAAK,WAAU,sCAAsC,mBAAQ;AAAA,QAC7D,YACC,oBAAC,UAAK,WAAU,4CACb,qBACH,IACE;AAAA,SACN;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,SAAS;AAAA,UACT,WAAU;AAAA,UACX;AAAA;AAAA,MAED;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,cAAY,UAAU,OAAO;AAAA,UAC7B,SAAS;AAAA,UACT,WAAU;AAAA,UAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,MACxB;AAAA,OACF;AAAA,EAEJ;AAEA,SACE,qBAAC,UAAK,WAAU,4HACd;AAAA,yBAAC,UAAK,WAAU,4CACd;AAAA,0BAAC,UAAK,WAAU,sCAAsC,mBAAQ;AAAA,MAC7D,YACC,oBAAC,UAAK,WAAU,gDACb,qBACH,IACE;AAAA,OACN;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAY,UAAU,OAAO;AAAA,QAC7B,SAAS;AAAA,QACT,WAAU;AAAA,QAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,IACxB;AAAA,KACF;AAEJ;AAWA,SAAS,sBAAsB;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,gBAAgB;AAClB,GAOG;AACD,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAS,EAAE;AAE3C,QAAM,YAAY,OAAO,aAAa;AAMtC,QAAM,cAAc,MAAM,OAAO,QAAQ;AACzC,QAAM,UAAU,MAAM;AACpB,gBAAY,UAAU;AAAA,EACxB,GAAG,CAAC,QAAQ,CAAC;AAKb,QAAM,UAAU,MAAM;AA5KxB;AA6KI,QAAI,WAAW;AACb,wBAAY,YAAZ,qCAAsB;AAAA,IACxB;AAAA,EACF,GAAG,CAAC,WAAW,KAAK,CAAC;AAErB,QAAM,kBAAkB,MAAM,KAAK,EAAE,YAAY;AACjD,QAAM,WAAW,YACb,WACA,kBACE,SAAS,OAAO,CAAC,YAAY;AAtLrC;AAuLU,UAAM,SAAQ,kBAAa,OAAO,MAApB,YAAyB;AACvC,WACE,QAAQ,KAAK,YAAY,EAAE,SAAS,eAAe,KACnD,QAAQ,KAAK,YAAY,EAAE,SAAS,eAAe,KACnD,MAAM,YAAY,EAAE,SAAS,eAAe;AAAA,EAEhD,CAAC,IACD;AAEN,QAAM,eAAe,aAAa,KAAK;AAEvC,WAAS,cAAc,OAA8C;AACnE,QAAI,MAAM,QAAQ,WAAW,cAAc;AACzC,YAAM,eAAe;AACrB,iBAAW,MAAM,KAAK,CAAC;AACvB,eAAS,EAAE;AAAA,IACb;AAAA,EACF;AAEA,SACE,iCACE;AAAA,yBAAC,SAAI,WAAU,iEACb;AAAA,0BAAC,UAAO,WAAU,yCAAwC;AAAA,MAC1D;AAAA,QAAC;AAAA;AAAA,UACC,WAAS;AAAA,UACT,OAAO;AAAA,UACP,UAAU,CAAC,UAAU,SAAS,MAAM,OAAO,KAAK;AAAA,UAChD,WAAW;AAAA,UACX,WAAU;AAAA,UACV,aAAY;AAAA;AAAA,MACd;AAAA,OACF;AAAA,IAEA,oBAAC,SAAI,MAAK,WAAU,WAAU,qCAC3B,0BACC,oBAAC,SAAI,WAAU,2DAA0D,mCAEzE,IACE,aAAa,gBAAgB,WAAW,IAC1C,oBAAC,SAAI,WAAU,2DAA0D,sDAEzE,IACE,CAAC,aAAa,SAAS,WAAW,IACpC,qBAAC,SAAI,WAAU,2DACb;AAAA,0BAAC,SAAI,WAAU,kCAAiC,0CAEhD;AAAA,MACA,oBAAC,SAAI,WAAU,QAAO,6EAEtB;AAAA,OACF,IACE,SAAS,WAAW,IACtB,qBAAC,SAAI,WAAU,2DACb;AAAA,2BAAC,SAAI;AAAA;AAAA,QAA2B;AAAA,QAAM;AAAA,SAAQ;AAAA,MAC7C,eACC,qBAAC,SAAI,WAAU,QAAO;AAAA;AAAA,QAAoB;AAAA,QAAM;AAAA,SAAC,IAC/C;AAAA,OACN,IAEA,SAAS,IAAI,CAAC,SAAS,UAAU;AAC/B,YAAM,QAAQ,aAAa,OAAO;AAClC,YAAM,UAAU,CAAC,SAAS,CAAC,aAAa,KAAK;AAC7C,YAAM,eAAe,QACjB,YAAY,IAAI,MAAM,YAAY,CAAC,IACnC;AACJ,YAAM,WAAW,WAAW;AAE5B,aACE;AAAA,QAAC;AAAA;AAAA,UAEC,MAAK;AAAA,UACL,iBAAe;AAAA,UACf,iBAAe;AAAA,UACf,SAAS,MAAM;AACb,gBAAI,CAAC,SAAU,UAAS,OAAO;AAAA,UACjC;AAAA,UACA,WAAW;AAAA,YACT;AAAA,YACA,YAAY;AAAA,UACd;AAAA,UAEA;AAAA,gCAAC,SAAI,WAAU,yHACZ,sBAAY,QAAQ,MAAM,wBAAS,GAAG,GACzC;AAAA,YACA,qBAAC,SAAI,WAAU,kBACb;AAAA,mCAAC,SAAI,WAAU,6BACb;AAAA,oCAAC,UAAK,WAAU,oDACb,kBAAQ,MACX;AAAA,gBACA,oBAAC,UAAK,WAAU,8CACb,kBAAQ,MACX;AAAA,iBACF;AAAA,cACC,QACC,oBAAC,SAAI,WAAU,8CACZ,iBACH,IACE;AAAA,eACN;AAAA,YACC,eACC,oBAAC,UAAK,WAAU,4DAA2D,mBAE3E,IACE,UACF,oBAAC,UAAK,WAAU,4DAA2D,sBAE3E,IACE;AAAA;AAAA;AAAA,QAtCC,GAAG,QAAQ,IAAI,IAAI,wBAAS,KAAK;AAAA,MAuCxC;AAAA,IAEJ,CAAC,GAEL;AAAA,IAEA,qBAAC,SAAI,WAAU,mGACb;AAAA,0BAAC,kBAAe,WAAU,mBAAkB;AAAA,MAC5C,oBAAC,UAAK,wEAA0D;AAAA,OAClE;AAAA,KACF;AAEJ;AAEO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR,WAAW,CAAC;AAAA,EACZ,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA6B;AAC3B,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAS,EAAE;AAC3C,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAAS,KAAK;AAExD,QAAM,iBAAiB,WAAW,KAAK,CAAC,MAAM,CAAC,EAAE,SAAS;AAC1D,QAAM,QACJ,SAAS,iBAAiB,UAAU;AACtC,QAAM,WAAW,UAAU;AAE3B,QAAM,QAAQ,oCAAe,oBAAI,IAAY;AAE7C,QAAM,sBACJ,oCAAgB,WAAW,SAAS,IAAI,mBAAmB;AAE7D,QAAM,oBAAoB,MAAM,OAAsB,IAAI;AAE1D,WAAS,SAAS,OAAwB;AACxC,UAAM,UAAU,MAAM,KAAK;AAC3B,QAAI,CAAC,aAAa,OAAO,EAAG,QAAO;AACnC,QAAI,MAAM,IAAI,QAAQ,YAAY,CAAC,EAAG,QAAO;AAC7C,uBAAmB;AAAA,MACjB,GAAG;AAAA,MACH,EAAE,IAAI,SAAS,OAAO,SAAS,MAAM,IAAI,WAAW,MAAM;AAAA,IAC5D,CAAC;AACD,aAAS,EAAE;AACX,WAAO;AAAA,EACT;AAEA,WAAS,cAAc,OAA8C;AACnE,SAAK,MAAM,QAAQ,WAAW,MAAM,QAAQ,QAAQ,MAAM,KAAK,GAAG;AAChE,YAAM,eAAe;AACrB,eAAS,KAAK;AACd;AAAA,IACF;AACA,QAAI,MAAM,QAAQ,SAAS,MAAM,KAAK,GAAG;AACvC,YAAM,UAAU,MAAM,KAAK;AAC3B,UAAI,SAAS,OAAO,GAAG;AACrB,0BAAkB,UAAU,QAAQ,YAAY;AAAA,MAClD;AACA;AAAA,IACF;AACA,QAAI,MAAM,QAAQ,eAAe,UAAU,MAAM,WAAW,SAAS,GAAG;AACtE,YAAM,eAAe;AACrB,yBAAmB,WAAW,MAAM,GAAG,EAAE,CAAC;AAAA,IAC5C;AAAA,EACF;AAEA,WAAS,iBAAiB,IAAY;AACpC;AAAA,MACE,WAAW,IAAI,CAAC,MAAO,EAAE,OAAO,KAAK,iCAAK,IAAL,EAAQ,WAAW,KAAK,KAAI,CAAE;AAAA,IACrE;AAAA,EACF;AAEA,WAAS,gBAAgB,IAAY;AACnC,uBAAmB,WAAW,OAAO,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;AAAA,EAC1D;AAEA,WAAS,cAAc,SAA2B;AAzXpD;AA0XI,UAAM,aACJ,8DAAqB,aAArB,YACC;AAAA,MACC,KAAI,kBAAa,OAAO,MAApB,YAAyB,QAAQ;AAAA,MACrC,QAAO,kBAAa,OAAO,MAApB,YAAyB;AAAA,MAChC,MAAM,QAAQ;AAAA,MACd,WAAW;AAAA,IACb;AACF,uBAAmB,CAAC,GAAG,YAAY,SAAS,CAAC;AAC7C,kBAAc,KAAK;AAAA,EACrB;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA,YAAY;AAAA,MACd;AAAA,MAEA;AAAA;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,YAAY;AAAA,YACd;AAAA,YAEC;AAAA;AAAA,QACH;AAAA,QAEA,qBAAC,SAAI,WAAU,WACb;AAAA,+BAAC,SAAI,WAAU,uCACZ;AAAA,uBAAW,IAAI,CAAC,cACf;AAAA,cAAC;AAAA;AAAA,gBAEC;AAAA,gBACA,WAAW,MAAM,iBAAiB,UAAU,EAAE;AAAA,gBAC9C,UAAU,MAAM,gBAAgB,UAAU,EAAE;AAAA;AAAA,cAHvC,UAAU;AAAA,YAIjB,CACD;AAAA,YACD;AAAA,cAAC;AAAA;AAAA,gBACC;AAAA,gBACA,UAAU,CAAC,UAAU,SAAS,MAAM,OAAO,KAAK;AAAA,gBAChD,WAAW;AAAA,gBACX,QAAQ,MAAM;AACZ,wBAAM,UAAU,MAAM,KAAK,EAAE,YAAY;AACzC,sBAAI,WAAW,kBAAkB,YAAY,SAAS;AACpD,sCAAkB,UAAU;AAC5B;AAAA,kBACF;AAGA,2BAAS,KAAK;AAAA,gBAChB;AAAA,gBACA,aAAa;AAAA,gBACb,WAAU;AAAA;AAAA,YACZ;AAAA,aACF;AAAA,UAEC,cAAc,YACb,qBAAC,SAAI,WAAU,qBACZ;AAAA,yBACC,qBAAC,iBAAiB,MAAjB,EAAsB,MAAM,YAAY,cAAc,eACrD;AAAA,kCAAC,iBAAiB,SAAjB,EAAyB,SAAO,MAC/B;AAAA,gBAAC;AAAA;AAAA,kBACC,MAAK;AAAA,kBACL,WAAU;AAAA,kBAEV;AAAA,wCAAC,SAAM,WAAU,UAAS;AAAA,oBAAE;AAAA,oBAE5B,oBAAC,eAAY,WAAU,UAAS;AAAA;AAAA;AAAA,cAClC,GACF;AAAA,cACA,oBAAC,iBAAiB,QAAjB,EACC;AAAA,gBAAC,iBAAiB;AAAA,gBAAjB;AAAA,kBACC,MAAK;AAAA,kBACL,OAAM;AAAA,kBACN,YAAY;AAAA,kBACZ,kBAAkB;AAAA,kBAClB,WAAU;AAAA,kBAEV;AAAA,oBAAC;AAAA;AAAA,sBACC;AAAA,sBACA,aAAa;AAAA,sBACb,UAAU;AAAA,sBACV,YAAY,CAAC,UAAU;AACrB,iCAAS,KAAK;AACd,sCAAc,KAAK;AAAA,sBACrB;AAAA,sBACA;AAAA,sBACA;AAAA;AAAA,kBACF;AAAA;AAAA,cACF,GACF;AAAA,eACF,IACE;AAAA,YACH,YACC;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,SAAS;AAAA,gBACT,WAAU;AAAA,gBAEV;AAAA,sCAAC,QAAK,WAAU,UAAS;AAAA,kBACxB,YAAY,gBAAgB;AAAA;AAAA;AAAA,YAC/B,IACE;AAAA,aACN,IACE;AAAA,WACN;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
@@ -12,7 +12,7 @@ import { VariantProps } from 'class-variance-authority';
12
12
  */
13
13
  type PillStatus = "success" | "warning" | "error" | "neutral" | "info";
14
14
  declare const pillVariants: (props?: ({
15
- variant?: "error" | "default" | "secondary" | "destructive" | "outline" | "ghost" | "neutral" | "info" | "success" | "warning" | null | undefined;
15
+ variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "error" | "neutral" | "info" | "success" | "warning" | null | undefined;
16
16
  } & class_variance_authority_types.ClassProp) | undefined) => string;
17
17
  interface PillProps extends React.ComponentProps<"span">, VariantProps<typeof pillVariants> {
18
18
  }
@@ -5,7 +5,7 @@ import { Tabs as Tabs$1 } from 'radix-ui';
5
5
 
6
6
  declare function Tabs({ className, orientation, ...props }: React.ComponentProps<typeof Tabs$1.Root>): React.JSX.Element;
7
7
  declare const tabsListVariants: (props?: ({
8
- variant?: "line" | "default" | null | undefined;
8
+ variant?: "default" | "line" | null | undefined;
9
9
  } & class_variance_authority_types.ClassProp) | undefined) => string;
10
10
  declare function TabsList({ className, variant, ...props }: React.ComponentProps<typeof Tabs$1.List> & VariantProps<typeof tabsListVariants>): React.JSX.Element;
11
11
  declare function TabsTrigger({ className, ...props }: React.ComponentProps<typeof Tabs$1.Trigger>): React.JSX.Element;
package/dist/index.d.ts CHANGED
@@ -27,7 +27,7 @@ export { CheckInsCard, RecentlyCompletedCard, TopTasksCard, UpcomingMeetingsCard
27
27
  export { DataRow, DataTable, DataTableProps } from './components/data-table.js';
28
28
  export { ConditionFieldDef, ConditionFieldOption, ConditionFilterValue, ConditionOperator, ConditionOptionObject, DEFAULT_OPERATORS, DataTableConditionFilter, DataTableConditionFilterProps, OPERATOR_LABELS, generateConditionId, getOperators, shouldShowOptionSearch } from './components/data-table-condition-filter.js';
29
29
  export { DataTableDisplay, DataTableDisplayColumn } from './components/data-table-display.js';
30
- export { DataTableFilter, DataTableFilterCategory, DataTableFilterProps, FilterOption } from './components/data-table-filter.js';
30
+ export { DataTableFilter, DataTableFilterCategory, DataTableFilterProps, DataTableOptionFilterCategory, DataTableTextFilterCategory, FilterOption } from './components/data-table-filter.js';
31
31
  export { DataTableQuickViewValue, DataTableQuickViews } from './components/data-table-quick-views.js';
32
32
  export { DataTableToolbar } from './components/data-table-toolbar.js';
33
33
  export { Citation, DetailViewHeader, DetailViewSummary, DetailViewThread, SourceDef, SourceList, ThreadMessage } from './components/detail-view.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handled-ai/design-system",
3
- "version": "0.18.43",
3
+ "version": "0.18.45",
4
4
  "description": "Handled UI component library (shadcn-style, New York)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.12.0",
@@ -264,6 +264,136 @@ describe("DataTableFilter", () => {
264
264
  expect(openItem!.querySelector("span.rounded-full")).toBeNull();
265
265
  });
266
266
 
267
+
268
+ it("renders a text category as a submenu with a text input", () => {
269
+ const textCategory: DataTableFilterCategory = {
270
+ id: "callsign",
271
+ label: "Callsign",
272
+ icon: ListFilter,
273
+ type: "text",
274
+ valuePlaceholder: "Enter callsign",
275
+ };
276
+
277
+ render(
278
+ <DataTableFilter
279
+ categories={[textCategory]}
280
+ selectedFilters={{}}
281
+ onToggleFilter={() => {}}
282
+ />
283
+ );
284
+
285
+ const subTrigger = document.querySelector('[data-slot="dropdown-menu-sub-trigger"]');
286
+ expect(subTrigger).not.toBeNull();
287
+ expect(subTrigger!.textContent).toContain("Callsign");
288
+ expect(screen.getByLabelText("Callsign")).toBeDefined();
289
+ expect(screen.getByPlaceholderText("Enter callsign")).toBeDefined();
290
+ expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
291
+ expect(screen.queryByText("No matches")).toBeNull();
292
+ });
293
+
294
+ it("applies a trimmed text filter value", () => {
295
+ const onTextFilterChange = vi.fn();
296
+ const textCategory: DataTableFilterCategory = {
297
+ id: "callsign",
298
+ label: "Callsign",
299
+ icon: ListFilter,
300
+ type: "text",
301
+ valuePlaceholder: "Enter callsign",
302
+ };
303
+
304
+ render(
305
+ <DataTableFilter
306
+ categories={[textCategory]}
307
+ selectedFilters={{}}
308
+ onToggleFilter={() => {}}
309
+ textFilters={{}}
310
+ onTextFilterChange={onTextFilterChange}
311
+ />
312
+ );
313
+
314
+ fireEvent.change(screen.getByLabelText("Callsign"), {
315
+ target: { value: " M42 " },
316
+ });
317
+ fireEvent.click(screen.getByRole("button", { name: "Apply" }));
318
+
319
+ expect(onTextFilterChange).toHaveBeenCalledWith("callsign", "M42");
320
+ });
321
+
322
+ it("clears an active text filter value", () => {
323
+ const onTextFilterChange = vi.fn();
324
+ const textCategory: DataTableFilterCategory = {
325
+ id: "organizationId",
326
+ label: "Organization ID",
327
+ icon: ListFilter,
328
+ type: "text",
329
+ };
330
+
331
+ render(
332
+ <DataTableFilter
333
+ categories={[textCategory]}
334
+ selectedFilters={{}}
335
+ onToggleFilter={() => {}}
336
+ textFilters={{ organizationId: "ORG-123" }}
337
+ onTextFilterChange={onTextFilterChange}
338
+ />
339
+ );
340
+
341
+ expect(screen.getByLabelText("Organization ID")).toHaveProperty("value", "ORG-123");
342
+ fireEvent.click(screen.getByRole("button", { name: "Clear" }));
343
+
344
+ expect(onTextFilterChange).toHaveBeenCalledWith("organizationId", "");
345
+ });
346
+
347
+ it("includes active text filters in the filter count", () => {
348
+ const textCategory: DataTableFilterCategory = {
349
+ id: "callsign",
350
+ label: "Callsign",
351
+ icon: ListFilter,
352
+ type: "text",
353
+ };
354
+ const optionCategory: DataTableFilterCategory = {
355
+ id: "status",
356
+ label: "Status",
357
+ icon: ListFilter,
358
+ options: ["Open", "Closed"],
359
+ };
360
+
361
+ render(
362
+ <DataTableFilter
363
+ categories={[textCategory, optionCategory]}
364
+ selectedFilters={{ status: ["Open"] }}
365
+ onToggleFilter={() => {}}
366
+ textFilters={{ callsign: " M42 " }}
367
+ />
368
+ );
369
+
370
+ const badge = screen.getByText("2");
371
+ expect(badge.tagName).toBe("SPAN");
372
+ expect(badge.className).toContain("bg-muted");
373
+ });
374
+
375
+ it("does not count blank text filter values as active", () => {
376
+ const textCategory: DataTableFilterCategory = {
377
+ id: "callsign",
378
+ label: "Callsign",
379
+ icon: ListFilter,
380
+ type: "text",
381
+ };
382
+
383
+ render(
384
+ <DataTableFilter
385
+ categories={[textCategory]}
386
+ selectedFilters={{}}
387
+ onToggleFilter={() => {}}
388
+ textFilters={{ callsign: " " }}
389
+ />
390
+ );
391
+
392
+ const triggerButton = document.querySelector('[data-slot="dropdown-menu-trigger"]');
393
+ expect(triggerButton).not.toBeNull();
394
+ expect(triggerButton!.querySelector("span.bg-muted")).toBeNull();
395
+ });
396
+
267
397
  it("does not expose the condition builder entry point without condition fields", () => {
268
398
  render(<DataTableFilter {...defaultProps} />);
269
399
 
@@ -58,6 +58,53 @@ describe("EmailRecipientField", () => {
58
58
  ])
59
59
  })
60
60
 
61
+ it("adds an unconfirmed chip on Tab without trapping focus", () => {
62
+ const onChange = vi.fn()
63
+ render(
64
+ <EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
65
+ )
66
+
67
+ const input = getInput()
68
+ fireEvent.change(input, { target: { value: "tab@example.com" } })
69
+ const wasNotCancelled = fireEvent.keyDown(input, { key: "Tab" })
70
+
71
+ expect(wasNotCancelled).toBe(true)
72
+ expect(onChange).toHaveBeenCalledWith([
73
+ { id: "tab@example.com", email: "tab@example.com", name: "", confirmed: false },
74
+ ])
75
+ })
76
+
77
+ it("adds an unconfirmed chip on blur", () => {
78
+ const onChange = vi.fn()
79
+ render(
80
+ <EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
81
+ )
82
+
83
+ const input = getInput()
84
+ fireEvent.change(input, { target: { value: "blur@example.com" } })
85
+ fireEvent.blur(input)
86
+
87
+ expect(onChange).toHaveBeenCalledWith([
88
+ { id: "blur@example.com", email: "blur@example.com", name: "", confirmed: false },
89
+ ])
90
+ })
91
+
92
+ it("uses the roomy confirmation chip treatment for unconfirmed recipients", () => {
93
+ render(
94
+ <EmailRecipientField
95
+ label="To"
96
+ recipients={[{ id: "a@b.com", email: "a@b.com", name: "Ada Lovelace", confirmed: false }]}
97
+ onRecipientsChange={vi.fn()}
98
+ />,
99
+ )
100
+
101
+ const confirmButton = screen.getByRole("button", { name: "Confirm" })
102
+ expect(confirmButton.className).toContain("bg-amber-200/60")
103
+ expect(confirmButton.className).toContain("px-2")
104
+ expect(screen.getByText("Ada Lovelace").className).toContain("font-medium")
105
+ expect(screen.getByText("a@b.com").className).toContain("text-amber-800/70")
106
+ })
107
+
61
108
  it("does not add an invalid email", () => {
62
109
  const onChange = vi.fn()
63
110
  render(
@@ -28,13 +28,10 @@ export interface FilterOption {
28
28
  value: string
29
29
  }
30
30
 
31
- export interface DataTableFilterCategory {
31
+ interface DataTableFilterCategoryBase {
32
32
  id: string
33
33
  label: string
34
34
  icon: React.ComponentType<{ className?: string }>
35
- options: (string | FilterOption)[]
36
- /** Filter behavior. Defaults to "multi" (checkbox multi-select). */
37
- type?: "multi" | "single" | "boolean"
38
35
  /**
39
36
  * Submenu search behavior. Defaults to the DataTableFilter
40
37
  * optionSearchThreshold prop. Use true to always show search or false to
@@ -43,6 +40,25 @@ export interface DataTableFilterCategory {
43
40
  searchable?: boolean | { threshold?: number }
44
41
  }
45
42
 
43
+ export interface DataTableOptionFilterCategory extends DataTableFilterCategoryBase {
44
+ options: (string | FilterOption)[]
45
+ /** Filter behavior. Defaults to "multi" (checkbox multi-select). */
46
+ type?: "multi" | "single" | "boolean"
47
+ }
48
+
49
+ export interface DataTableTextFilterCategory extends DataTableFilterCategoryBase {
50
+ /** Free-text filter behavior. Renders a top-level submenu with a text input. */
51
+ type: "text"
52
+ /** Placeholder shown in the text filter input. */
53
+ valuePlaceholder?: string
54
+ /** Not used for text filters; optional for backwards-compatible category shapes. */
55
+ options?: (string | FilterOption)[]
56
+ }
57
+
58
+ export type DataTableFilterCategory =
59
+ | DataTableOptionFilterCategory
60
+ | DataTableTextFilterCategory
61
+
46
62
  function getOptionValue(option: string | FilterOption): string {
47
63
  return typeof option === "string" ? option : option.value
48
64
  }
@@ -50,6 +66,111 @@ function getOptionLabel(option: string | FilterOption): string {
50
66
  return typeof option === "string" ? option : option.label
51
67
  }
52
68
 
69
+ function isTextFilterCategory(
70
+ category: DataTableFilterCategory
71
+ ): category is DataTableTextFilterCategory {
72
+ return category.type === "text"
73
+ }
74
+
75
+ function TextFilterSubmenu({
76
+ category,
77
+ value,
78
+ onValueChange,
79
+ }: {
80
+ category: DataTableTextFilterCategory
81
+ value: string
82
+ onValueChange?: (categoryId: string, value: string) => void
83
+ }) {
84
+ const [draftValue, setDraftValue] = React.useState(value)
85
+
86
+ React.useEffect(() => {
87
+ setDraftValue(value)
88
+ }, [value])
89
+
90
+ const active = value.trim().length > 0
91
+ const applyValue = React.useCallback(() => {
92
+ onValueChange?.(category.id, draftValue.trim())
93
+ }, [category.id, draftValue, onValueChange])
94
+
95
+ return (
96
+ <DropdownMenuSub
97
+ onOpenChange={(open) => {
98
+ if (!open) {
99
+ setDraftValue(value)
100
+ }
101
+ }}
102
+ >
103
+ <DropdownMenuSubTrigger
104
+ className={cn(
105
+ "cursor-pointer py-1.5 text-xs",
106
+ active && "text-brand-purple"
107
+ )}
108
+ >
109
+ <category.icon
110
+ className={cn(
111
+ "mr-2 h-3.5 w-3.5 text-muted-foreground",
112
+ active && "text-brand-purple"
113
+ )}
114
+ />
115
+ {category.label}
116
+ {active ? <Check className="ml-auto h-4 w-4" /> : null}
117
+ </DropdownMenuSubTrigger>
118
+ <DropdownMenuSubContent className="w-64 p-2">
119
+ <div className="space-y-2">
120
+ <input
121
+ aria-label={category.label}
122
+ className="h-8 w-full rounded-md bg-muted/50 px-2 py-1 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:bg-muted"
123
+ placeholder={
124
+ category.valuePlaceholder ??
125
+ `Enter ${category.label.toLowerCase()}...`
126
+ }
127
+ value={draftValue}
128
+ onChange={(event) => setDraftValue(event.target.value)}
129
+ onClick={(event) => event.stopPropagation()}
130
+ onKeyDown={(event) => {
131
+ event.stopPropagation()
132
+ if (event.key === "Enter") {
133
+ event.preventDefault()
134
+ applyValue()
135
+ }
136
+ }}
137
+ />
138
+ <div className="flex items-center justify-end gap-2">
139
+ {active ? (
140
+ <Button
141
+ type="button"
142
+ variant="ghost"
143
+ size="sm"
144
+ className="h-7 px-2 text-xs"
145
+ onClick={(event) => {
146
+ event.preventDefault()
147
+ event.stopPropagation()
148
+ setDraftValue("")
149
+ onValueChange?.(category.id, "")
150
+ }}
151
+ >
152
+ Clear
153
+ </Button>
154
+ ) : null}
155
+ <Button
156
+ type="button"
157
+ size="sm"
158
+ className="h-7 px-2 text-xs"
159
+ onClick={(event) => {
160
+ event.preventDefault()
161
+ event.stopPropagation()
162
+ applyValue()
163
+ }}
164
+ >
165
+ Apply
166
+ </Button>
167
+ </div>
168
+ </div>
169
+ </DropdownMenuSubContent>
170
+ </DropdownMenuSub>
171
+ )
172
+ }
173
+
53
174
  export interface DataTableFilterProps {
54
175
  categories: DataTableFilterCategory[]
55
176
  selectedFilters: Record<string, string[]>
@@ -71,6 +192,10 @@ export interface DataTableFilterProps {
71
192
  onConditionFiltersChange?: (conditions: ConditionFilterValue[]) => void
72
193
  /** Dropdown entry label for the condition-builder panel. Default: "Add filter". */
73
194
  conditionBuilderLabel?: string
195
+ /** Active free-text filters keyed by category id. */
196
+ textFilters?: Record<string, string>
197
+ /** Callback when a free-text filter value is applied or cleared. */
198
+ onTextFilterChange?: (categoryId: string, value: string) => void
74
199
  }
75
200
 
76
201
  export function DataTableFilter({
@@ -86,6 +211,8 @@ export function DataTableFilter({
86
211
  conditionFilters = [],
87
212
  onConditionFiltersChange,
88
213
  conditionBuilderLabel = "Add filter",
214
+ textFilters = {},
215
+ onTextFilterChange,
89
216
  }: DataTableFilterProps) {
90
217
  const [query, setQuery] = React.useState("")
91
218
  const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})
@@ -103,6 +230,10 @@ export function DataTableFilter({
103
230
  return true
104
231
  }
105
232
 
233
+ if (isTextFilterCategory(category)) {
234
+ return false
235
+ }
236
+
106
237
  return category.options.some((option) =>
107
238
  getOptionLabel(option).toLowerCase().includes(normalized)
108
239
  )
@@ -123,8 +254,16 @@ export function DataTableFilter({
123
254
  0
124
255
  )
125
256
 
126
- return userCount + conditionFilters.length
127
- }, [selectedFilters, conditionFilters.length])
257
+ const textCount = categories.reduce((count, category) => {
258
+ if (!isTextFilterCategory(category)) {
259
+ return count
260
+ }
261
+
262
+ return textFilters[category.id]?.trim() ? count + 1 : count
263
+ }, 0)
264
+
265
+ return userCount + conditionFilters.length + textCount
266
+ }, [categories, selectedFilters, conditionFilters.length, textFilters])
128
267
 
129
268
  /** Collect all preset chips to render */
130
269
  const presetChips = React.useMemo(() => {
@@ -135,9 +274,9 @@ export function DataTableFilter({
135
274
  for (const [categoryId, values] of Object.entries(presetFilters)) {
136
275
  const category = categories.find((c) => c.id === categoryId)
137
276
  for (const value of values) {
138
- const option = category?.options.find(
139
- (opt) => getOptionValue(opt) === value
140
- )
277
+ const option = category && !isTextFilterCategory(category)
278
+ ? category.options.find((opt) => getOptionValue(opt) === value)
279
+ : undefined
141
280
  const label = option ? getOptionLabel(option) : value
142
281
  const active = selectedFilters[categoryId]?.includes(value) ?? false
143
282
  chips.push({ categoryId, value, label, active })
@@ -208,6 +347,18 @@ export function DataTableFilter({
208
347
  )
209
348
  }
210
349
 
350
+ /* ── Free-text submenu ───────────────────────────────── */
351
+ if (isTextFilterCategory(category)) {
352
+ return (
353
+ <TextFilterSubmenu
354
+ key={category.id}
355
+ category={category}
356
+ value={textFilters[category.id] ?? ""}
357
+ onValueChange={onTextFilterChange}
358
+ />
359
+ )
360
+ }
361
+
211
362
  /* ── Sub-menu (single / multi) ──────────────────────── */
212
363
  const subQuery = (subQueries[category.id] ?? "").trim().toLowerCase()
213
364
  const filteredOptions = subQuery
@@ -3,7 +3,6 @@
3
3
  import * as React from "react"
4
4
  import { Popover as PopoverPrimitive } from "radix-ui"
5
5
  import {
6
- Check,
7
6
  ChevronDown,
8
7
  CornerDownLeft,
9
8
  Plus,
@@ -75,16 +74,25 @@ function RecipientChipPill({
75
74
  onConfirm: () => void
76
75
  onRemove: () => void
77
76
  }) {
78
- const display = recipient.name || recipient.email
77
+ const primary = recipient.name || recipient.email
78
+ const secondary = recipient.name ? recipient.email : ""
79
+ const display = primary || recipient.email
79
80
 
80
81
  if (!recipient.confirmed) {
81
82
  return (
82
- <span className="inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-amber-300 bg-amber-50 text-amber-900">
83
- <span className="truncate max-w-[180px]">{display}</span>
83
+ <span className="inline-flex max-w-full items-center gap-1 rounded text-xs border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800">
84
+ <span className="min-w-0 inline-flex items-baseline gap-1">
85
+ <span className="truncate font-medium max-w-[150px]">{primary}</span>
86
+ {secondary ? (
87
+ <span className="truncate max-w-[190px] text-amber-800/70">
88
+ {secondary}
89
+ </span>
90
+ ) : null}
91
+ </span>
84
92
  <button
85
93
  type="button"
86
94
  onClick={onConfirm}
87
- className="text-[10.5px] font-semibold px-[7px] py-0.5 rounded bg-amber-300/50 hover:bg-amber-300/85"
95
+ className="ml-1 rounded bg-amber-200/60 px-2 py-0.5 text-xs font-semibold text-amber-800 hover:bg-amber-200"
88
96
  >
89
97
  Confirm
90
98
  </button>
@@ -92,7 +100,7 @@ function RecipientChipPill({
92
100
  type="button"
93
101
  aria-label={`Remove ${display}`}
94
102
  onClick={onRemove}
95
- className="inline-flex items-center justify-center size-[17px] rounded text-amber-700/80 hover:bg-amber-300/40"
103
+ className="inline-flex size-4 items-center justify-center rounded text-amber-700/80 hover:bg-amber-200/70"
96
104
  >
97
105
  <X className="size-3" />
98
106
  </button>
@@ -101,16 +109,20 @@ function RecipientChipPill({
101
109
  }
102
110
 
103
111
  return (
104
- <span className="inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-border bg-muted/50 text-foreground">
105
- <span className="inline-flex items-center justify-center size-[17px] rounded bg-emerald-50 text-emerald-700">
106
- <Check className="size-3" />
112
+ <span className="inline-flex max-w-full items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-xs text-foreground">
113
+ <span className="min-w-0 inline-flex items-baseline gap-1">
114
+ <span className="truncate font-medium max-w-[150px]">{primary}</span>
115
+ {secondary ? (
116
+ <span className="truncate max-w-[190px] text-muted-foreground">
117
+ {secondary}
118
+ </span>
119
+ ) : null}
107
120
  </span>
108
- <span className="truncate max-w-[180px]">{display}</span>
109
121
  <button
110
122
  type="button"
111
123
  aria-label={`Remove ${display}`}
112
124
  onClick={onRemove}
113
- className="inline-flex items-center justify-center size-[17px] rounded text-muted-foreground hover:bg-muted"
125
+ className="inline-flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
114
126
  >
115
127
  <X className="size-3" />
116
128
  </button>
@@ -320,23 +332,33 @@ export function EmailRecipientField({
320
332
  const resolvedPlaceholder =
321
333
  placeholder ?? (recipients.length > 0 ? "Add another..." : "Add email...")
322
334
 
323
- function addEmail(email: string) {
335
+ const committedByKeyRef = React.useRef<string | null>(null)
336
+
337
+ function addEmail(email: string): boolean {
324
338
  const trimmed = email.trim()
325
- if (!isValidEmail(trimmed)) return
326
- if (added.has(trimmed.toLowerCase())) return
339
+ if (!isValidEmail(trimmed)) return false
340
+ if (added.has(trimmed.toLowerCase())) return false
327
341
  onRecipientsChange([
328
342
  ...recipients,
329
343
  { id: trimmed, email: trimmed, name: "", confirmed: false },
330
344
  ])
331
345
  setValue("")
346
+ return true
332
347
  }
333
348
 
334
349
  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
335
- if ((event.key === "Enter" || event.key === ",") && isValidEmail(value)) {
350
+ if ((event.key === "Enter" || event.key === ",") && value.trim()) {
336
351
  event.preventDefault()
337
352
  addEmail(value)
338
353
  return
339
354
  }
355
+ if (event.key === "Tab" && value.trim()) {
356
+ const trimmed = value.trim()
357
+ if (addEmail(trimmed)) {
358
+ committedByKeyRef.current = trimmed.toLowerCase()
359
+ }
360
+ return
361
+ }
340
362
  if (event.key === "Backspace" && value === "" && recipients.length > 0) {
341
363
  event.preventDefault()
342
364
  onRecipientsChange(recipients.slice(0, -1))
@@ -397,9 +419,14 @@ export function EmailRecipientField({
397
419
  onChange={(event) => setValue(event.target.value)}
398
420
  onKeyDown={handleKeyDown}
399
421
  onBlur={() => {
422
+ const trimmed = value.trim().toLowerCase()
423
+ if (trimmed && committedByKeyRef.current === trimmed) {
424
+ committedByKeyRef.current = null
425
+ return
426
+ }
400
427
  // Commit any valid pending email so it is not silently dropped
401
428
  // when the user clicks Send without pressing Enter/comma first.
402
- if (isValidEmail(value)) addEmail(value)
429
+ addEmail(value)
403
430
  }}
404
431
  placeholder={resolvedPlaceholder}
405
432
  className="min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground"