@handled-ai/design-system 0.18.50 → 0.18.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/data-table-filter.d.ts +21 -6
  4. package/dist/components/data-table-filter.js +134 -9
  5. package/dist/components/data-table-filter.js.map +1 -1
  6. package/dist/components/pill.d.ts +1 -1
  7. package/dist/components/score-why-chips.d.ts +1 -1
  8. package/dist/components/signal-feedback-inline.d.ts +28 -12
  9. package/dist/components/signal-feedback-inline.js +146 -10
  10. package/dist/components/signal-feedback-inline.js.map +1 -1
  11. package/dist/components/signal-priority-popover.d.ts +1 -1
  12. package/dist/components/signal-priority-popover.js +7 -15
  13. package/dist/components/signal-priority-popover.js.map +1 -1
  14. package/dist/components/tabs.d.ts +1 -1
  15. package/dist/index.d.ts +3 -3
  16. package/dist/prototype/index.d.ts +1 -1
  17. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  18. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  19. package/dist/prototype/prototype-config.d.ts +1 -1
  20. package/dist/prototype/prototype-inbox-view.d.ts +3 -3
  21. package/dist/prototype/prototype-inbox-view.js +1 -2
  22. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  23. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  24. package/dist/prototype/prototype-shell.d.ts +1 -1
  25. package/dist/{signal-priority-popover-Cl98xw1n.d.ts → signal-priority-popover-QJngMAj7.d.ts} +4 -9
  26. package/package.json +1 -1
  27. package/src/components/__tests__/case-panel-why.test.tsx +126 -0
  28. package/src/components/__tests__/data-table-filter.test.tsx +130 -0
  29. package/src/components/__tests__/signal-priority-popover.test.tsx +4 -27
  30. package/src/components/data-table-filter.tsx +160 -9
  31. package/src/components/signal-feedback-inline.tsx +181 -20
  32. package/src/components/signal-priority-popover.tsx +6 -16
  33. package/src/prototype/__tests__/detail-view-opportunity-preview.test.tsx +90 -0
  34. package/src/prototype/__tests__/detail-view-score-why.test.tsx +0 -32
  35. package/src/prototype/prototype-config.ts +3 -5
  36. package/src/prototype/prototype-inbox-view.tsx +3 -4
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { h as InsightsViewConfig } from '../signal-priority-popover-Cl98xw1n.js';
2
+ import { h as InsightsViewConfig } from '../signal-priority-popover-QJngMAj7.js';
3
3
  import '../components/feedback-primitives.js';
4
4
  import '../components/quick-action-sidebar-nav.js';
5
5
  import '../components/quick-action-modal.js';
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { j as PrototypeConfig } from '../signal-priority-popover-Cl98xw1n.js';
2
+ import { j as PrototypeConfig } from '../signal-priority-popover-QJngMAj7.js';
3
3
  import '../components/feedback-primitives.js';
4
4
  import '../components/quick-action-sidebar-nav.js';
5
5
  import '../components/quick-action-modal.js';
@@ -10,7 +10,7 @@ import { DataRow } from './components/data-table.js';
10
10
  import { MetricCardProps } from './components/metric-card.js';
11
11
  import { PipelineStage, PipelineStageMetrics, PipelineStageTiming } from './charts/pipeline-overview.js';
12
12
  import { TimelineEvent } from './components/timeline-activity.js';
13
- import { ApprovalState } from './components/signal-feedback-inline.js';
13
+ import { OpportunityDraft, ApprovalState } from './components/signal-feedback-inline.js';
14
14
  import { LucideIcon } from 'lucide-react';
15
15
 
16
16
  interface TimelineSystemEventsConfig {
@@ -93,8 +93,6 @@ interface SignalScoreData {
93
93
  confidence: number;
94
94
  urgencyLabel?: SignalScoreUrgencyLabel;
95
95
  urgencyExplanation?: string;
96
- /** Controls whether the priority popover header shows the raw overall score number. @default "number" */
97
- priorityScoreDisplay?: SignalPriorityScoreDisplay;
98
96
  explanationBuckets?: SignalScoreExplanationBucket[];
99
97
  onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void;
100
98
  /** @deprecated The compact score UX no longer renders score-level thumbs by default. */
@@ -183,7 +181,7 @@ interface InboxViewConfig {
183
181
  }>;
184
182
  hideAccountsButton?: boolean;
185
183
  accountDetailsLabel?: string;
186
- onSignalApprove?: (item: QueueItem) => void | Promise<boolean>;
184
+ onSignalApprove?: (item: QueueItem, draft?: OpportunityDraft) => void | Promise<boolean>;
187
185
  getSignalApprovalState?: (item: QueueItem) => ApprovalState | undefined;
188
186
  signalLabels?: {
189
187
  approveButton?: string;
@@ -414,11 +412,8 @@ interface PriorityFactor {
414
412
  /** Evidence text (e.g. "$3.4M moved in 8h - current treasury balance $0.00"). */
415
413
  rationale: string;
416
414
  }
417
- type SignalPriorityScoreDisplay = "label" | "number";
418
415
  interface SignalPriorityPopoverProps {
419
416
  score: number;
420
- /** Controls whether the overall score number is shown in the popover header. @default "number" */
421
- scoreDisplay?: SignalPriorityScoreDisplay;
422
417
  urgencyLabel?: SignalScoreUrgencyLabel;
423
418
  /** Synthesis sentence displayed in the popover head. */
424
419
  urgencyExplanation?: string;
@@ -440,6 +435,6 @@ interface SignalPriorityPopoverProps {
440
435
  /** Persisted priority-level feedback for the footer. */
441
436
  initialPriorityFeedback?: PersistedFeedbackData | null;
442
437
  }
443
- declare function SignalPriorityPopover({ score, urgencyLabel: providedLabel, urgencyExplanation, factors, metaText, feedbackChips, onFeedbackSubmit, className, initialFactorFeedback, onFactorFeedback, initialPriorityFeedback, scoreDisplay, }: SignalPriorityPopoverProps): React.JSX.Element;
438
+ declare function SignalPriorityPopover({ score, urgencyLabel: providedLabel, urgencyExplanation, factors, metaText, feedbackChips, onFeedbackSubmit, className, initialFactorFeedback, onFactorFeedback, initialPriorityFeedback, }: SignalPriorityPopoverProps): React.JSX.Element;
444
439
 
445
- export { type AccountFilterTab as A, type BriefStyleVariant as B, type EntityPanelConfig as E, type InboxDetailSections as I, type PriorityFactor as P, type QueueItem as Q, SignalPriorityPopover as S, type TimelineSystemEventsConfig as T, type WorkQueueViewConfig as W, type AccountsViewConfig as a, type AdminTab as b, type AdminViewConfig as c, type EntityPanelSection as d, type InboxSortOption as e, type InboxViewConfig as f, type InsightsCustomTab as g, type InsightsViewConfig as h, type PrototypeBrandConfig as i, type PrototypeConfig as j, type SignalPriorityPopoverProps as k, type SignalScoreData as l, type SignalScoreExplanationBucket as m, type SignalScoreExplanationSignal as n, type SignalScoreUrgencyLabel as o, type SignalPriorityScoreDisplay as p };
440
+ export { type AccountFilterTab as A, type BriefStyleVariant as B, type EntityPanelConfig as E, type InboxDetailSections as I, type PriorityFactor as P, type QueueItem as Q, SignalPriorityPopover as S, type TimelineSystemEventsConfig as T, type WorkQueueViewConfig as W, type AccountsViewConfig as a, type AdminTab as b, type AdminViewConfig as c, type EntityPanelSection as d, type InboxSortOption as e, type InboxViewConfig as f, type InsightsCustomTab as g, type InsightsViewConfig as h, type PrototypeBrandConfig as i, type PrototypeConfig as j, type SignalPriorityPopoverProps as k, type SignalScoreData as l, type SignalScoreExplanationBucket as m, type SignalScoreExplanationSignal as n, type SignalScoreUrgencyLabel as o };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handled-ai/design-system",
3
- "version": "0.18.50",
3
+ "version": "0.18.52",
4
4
  "description": "Handled UI component library (shadcn-style, New York)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.12.0",
@@ -149,4 +149,130 @@ describe("CasePanelSignalApprovalActions", () => {
149
149
  expect(screen.getByText(/this will approve this action for/i)).toBeInTheDocument()
150
150
  expect(screen.getByRole("button", { name: /confirm/i }).className).toContain("h-7")
151
151
  })
152
+
153
+ it("lets callers collect edited opportunity preview fields before approval", async () => {
154
+ const onApprove = vi.fn()
155
+
156
+ renderApprovalActions({
157
+ initialApprovalState: "confirming",
158
+ onApprove,
159
+ opportunityPreview: {
160
+ name: "Churn Risk - Northwind Systems",
161
+ accountName: "Northwind Systems",
162
+ stage: "Prospecting",
163
+ closeDate: "Jun 30, 2026",
164
+ closeDateValue: "2026-06-30",
165
+ amount: "$75,000",
166
+ amountValue: 75000,
167
+ description: "Initial description",
168
+ churnType: "Churn Risk",
169
+ churnTypeOptions: ["Churn Risk", { value: "Win Back", label: "Win-back" }],
170
+ },
171
+ })
172
+
173
+ fireEvent.change(screen.getByLabelText("Close Date"), { target: { value: "2026-07-15" } })
174
+ expect(screen.getByLabelText("Amount")).toHaveValue("$75,000")
175
+ fireEvent.change(screen.getByLabelText("Amount"), { target: { value: "90000" } })
176
+ fireEvent.change(screen.getByLabelText("Churn Type"), { target: { value: "Win Back" } })
177
+ fireEvent.change(screen.getByLabelText("Description"), { target: { value: "Updated before create" } })
178
+ fireEvent.click(screen.getByRole("button", { name: /confirm/i }))
179
+
180
+ expect(onApprove).toHaveBeenCalledWith({
181
+ closeDate: "2026-07-15",
182
+ amount: "90000",
183
+ churnType: "Win Back",
184
+ description: "Updated before create",
185
+ })
186
+ })
187
+
188
+ it("keeps legacy display-only opportunity previews confirmable", () => {
189
+ const onApprove = vi.fn()
190
+
191
+ renderApprovalActions({
192
+ initialApprovalState: "confirming",
193
+ onApprove,
194
+ opportunityPreview: {
195
+ name: "Churn Risk - Northwind Systems",
196
+ accountName: "Northwind Systems",
197
+ stage: "Prospecting",
198
+ closeDate: "Jun 30, 2026",
199
+ amount: "$75,000",
200
+ },
201
+ })
202
+
203
+ expect(screen.getByText(/this will approve this action for/i)).toBeInTheDocument()
204
+ expect(screen.queryByLabelText("Close Date")).not.toBeInTheDocument()
205
+ expect(screen.getByRole("button", { name: /confirm/i })).not.toBeDisabled()
206
+
207
+ fireEvent.click(screen.getByRole("button", { name: /confirm/i }))
208
+ expect(onApprove).toHaveBeenCalledWith(undefined)
209
+ })
210
+
211
+ it("transitions to editable preview fields after async request approval in StrictMode", async () => {
212
+ function ApprovalPreviewHarness() {
213
+ const [opportunityPreview, setOpportunityPreview] = React.useState<React.ComponentProps<typeof SignalApproval.Root>["opportunityPreview"]>()
214
+
215
+ return (
216
+ <SignalApproval.Root
217
+ companyName="Northwind Systems"
218
+ labels={{ approveButton: "Create in Salesforce", dismissButton: "Not Helpful" }}
219
+ opportunityPreview={opportunityPreview}
220
+ onRequestApproval={async () => {
221
+ setOpportunityPreview({
222
+ name: "Churn Risk - Northwind Systems",
223
+ accountName: "Northwind Systems",
224
+ stage: "Prospecting",
225
+ closeDate: "Jun 30, 2026",
226
+ closeDateValue: "2026-06-30",
227
+ amount: "$75,000",
228
+ amountValue: 75000,
229
+ description: "Initial description",
230
+ churnType: "Churn Risk",
231
+ churnTypeOptions: ["Churn Risk", "Win Back"],
232
+ })
233
+ }}
234
+ >
235
+ <CasePanelSignalApprovalActions />
236
+ </SignalApproval.Root>
237
+ )
238
+ }
239
+
240
+ render(
241
+ <React.StrictMode>
242
+ <ApprovalPreviewHarness />
243
+ </React.StrictMode>,
244
+ )
245
+
246
+ fireEvent.click(screen.getByRole("button", { name: /create in salesforce/i }))
247
+
248
+ expect(await screen.findByLabelText("Close Date")).toHaveValue("2026-06-30")
249
+ expect(screen.getByLabelText("Amount")).toHaveValue("$75,000")
250
+ expect(screen.getByLabelText("Churn Type")).toHaveValue("Churn Risk")
251
+ expect(screen.getByLabelText("Description")).toHaveValue("Initial description")
252
+ expect(screen.getByRole("button", { name: /confirm/i })).not.toBeDisabled()
253
+ })
254
+
255
+ it("blocks confirmation until the close date is valid", () => {
256
+ const onApprove = vi.fn()
257
+
258
+ renderApprovalActions({
259
+ initialApprovalState: "confirming",
260
+ onApprove,
261
+ opportunityPreview: {
262
+ name: "Churn Risk - Northwind Systems",
263
+ accountName: "Northwind Systems",
264
+ stage: "Prospecting",
265
+ closeDate: "Jun 30, 2026",
266
+ closeDateValue: "2026-06-30",
267
+ amount: "$75,000",
268
+ },
269
+ })
270
+
271
+ fireEvent.change(screen.getByLabelText("Close Date"), { target: { value: "" } })
272
+ expect(screen.getByRole("button", { name: /confirm/i })).toBeDisabled()
273
+ expect(screen.getByText("Enter a valid close date.")).toBeInTheDocument()
274
+
275
+ fireEvent.click(screen.getByRole("button", { name: /confirm/i }))
276
+ expect(onApprove).not.toHaveBeenCalled()
277
+ })
152
278
  })
@@ -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
 
@@ -119,8 +119,8 @@ describe("SignalPriorityPopover", () => {
119
119
 
120
120
  // Check head section
121
121
  expect(content.textContent).toContain("Why this is high priority")
122
- expect(screen.getByTestId("priority-overall-score").textContent).toContain("79")
123
- expect(screen.getByTestId("priority-overall-score").textContent).toContain("/100")
122
+ expect(content.textContent).toContain("79")
123
+ expect(content.textContent).toContain("/100")
124
124
  expect(content.textContent).toContain("High range")
125
125
  expect(content.textContent).toContain("60-79")
126
126
  })
@@ -188,36 +188,13 @@ describe("SignalPriorityPopover", () => {
188
188
  expect(row.textContent).not.toContain("Raises0/100")
189
189
  })
190
190
 
191
- it("renders Contributing factors section label and calibrated priority copy", () => {
191
+ it("renders Contributing factors section label", () => {
192
192
  render(<SignalPriorityPopover {...defaultProps} />)
193
193
  fireEvent.click(screen.getByTestId("priority-popover-trigger"))
194
194
 
195
195
  const content = screen.getByTestId("priority-popover-content")
196
196
  expect(content.textContent).toContain("Contributing factors")
197
- expect(content.textContent).toContain("Priority = weighted signals + calibration")
198
- expect(content.textContent).not.toContain("Score = weighted sum")
199
- })
200
-
201
-
202
- it("renders the overall score number by default while preserving factor row scores", () => {
203
- render(<SignalPriorityPopover {...defaultProps} />)
204
- fireEvent.click(screen.getByTestId("priority-popover-trigger"))
205
-
206
- expect(screen.getByTestId("priority-overall-score").textContent).toBe("79/100")
207
- expect(screen.getByTestId("factor-row-test_severity").textContent).toContain("85/100")
208
- expect(screen.getByTestId("factor-row-account_depth").textContent).toContain("30/100")
209
- })
210
-
211
- it("hides only the overall header score in label display mode", () => {
212
- render(<SignalPriorityPopover {...defaultProps} scoreDisplay="label" />)
213
- fireEvent.click(screen.getByTestId("priority-popover-trigger"))
214
-
215
- const header = screen.getByTestId("priority-popover-header")
216
- expect(screen.queryByTestId("priority-overall-score")).toBeNull()
217
- expect(header.textContent).toContain("Why this is high priority")
218
- expect(header.textContent).not.toContain("79/100")
219
- expect(screen.getByTestId("factor-row-test_severity").textContent).toContain("85/100")
220
- expect(screen.getByTestId("factor-row-account_depth").textContent).toContain("30/100")
197
+ expect(content.textContent).toContain("Score = weighted sum")
221
198
  })
222
199
 
223
200
  it("renders score track bars with correct width percentage", () => {
@@ -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