@handled-ai/design-system 0.14.10 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/components/collapsible-section.d.ts +20 -0
  2. package/dist/components/collapsible-section.js +48 -0
  3. package/dist/components/collapsible-section.js.map +1 -0
  4. package/dist/components/contact-list.d.ts +3 -1
  5. package/dist/components/contact-list.js +20 -3
  6. package/dist/components/contact-list.js.map +1 -1
  7. package/dist/components/data-table-filter.d.ts +8 -2
  8. package/dist/components/data-table-filter.js +73 -8
  9. package/dist/components/data-table-filter.js.map +1 -1
  10. package/dist/components/entity-panel.js +1 -1
  11. package/dist/components/entity-panel.js.map +1 -1
  12. package/dist/components/virtualized-data-table.d.ts +16 -2
  13. package/dist/components/virtualized-data-table.js +153 -52
  14. package/dist/components/virtualized-data-table.js.map +1 -1
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -1
  18. package/package.json +1 -1
  19. package/src/components/__tests__/collapsible-section.test.tsx +143 -0
  20. package/src/components/__tests__/contact-list.test.tsx +116 -0
  21. package/src/components/__tests__/data-table-filter-presets.test.tsx +209 -0
  22. package/src/components/__tests__/entity-metadata-grid.test.tsx +25 -0
  23. package/src/components/__tests__/virtualized-data-table.test.tsx +556 -0
  24. package/src/components/collapsible-section.tsx +62 -0
  25. package/src/components/contact-list.tsx +22 -3
  26. package/src/components/data-table-filter.tsx +102 -12
  27. package/src/components/entity-panel.tsx +1 -1
  28. package/src/components/virtualized-data-table.tsx +174 -63
  29. package/src/index.ts +1 -0
@@ -34,13 +34,19 @@ function getOptionLabel(option: string | FilterOption): string {
34
34
  return typeof option === "string" ? option : option.label
35
35
  }
36
36
 
37
- interface DataTableFilterProps {
37
+ export interface DataTableFilterProps {
38
38
  categories: DataTableFilterCategory[]
39
39
  selectedFilters: Record<string, string[]>
40
40
  onToggleFilter: (categoryId: string, option: string) => void
41
41
  className?: string
42
42
  /** Minimum number of options before showing the sub-menu search input. Defaults to 8. */
43
43
  optionSearchThreshold?: number
44
+ /** Filters applied by default. Shown as distinct chips that can be toggled off but not dismissed. */
45
+ presetFilters?: Record<string, string[]>
46
+ /** Callback when a preset filter is toggled on/off. */
47
+ onTogglePreset?: (categoryId: string, option: string) => void
48
+ /** Label shown on preset chips to distinguish from user-applied filters. Default: "Default". */
49
+ presetLabel?: string
44
50
  }
45
51
 
46
52
  export function DataTableFilter({
@@ -49,6 +55,9 @@ export function DataTableFilter({
49
55
  onToggleFilter,
50
56
  className,
51
57
  optionSearchThreshold = 8,
58
+ presetFilters,
59
+ onTogglePreset,
60
+ presetLabel = "Default",
52
61
  }: DataTableFilterProps) {
53
62
  const [query, setQuery] = React.useState("")
54
63
  const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})
@@ -70,16 +79,61 @@ export function DataTableFilter({
70
79
  })
71
80
  }, [categories, query])
72
81
 
73
- const activeCount = React.useMemo(
74
- () =>
75
- Object.values(selectedFilters).reduce(
76
- (count, selected) => count + selected.length,
77
- 0
78
- ),
79
- [selectedFilters]
82
+ /** Check if a specific option is a preset filter */
83
+ const isPresetOption = React.useCallback(
84
+ (categoryId: string, value: string): boolean => {
85
+ return presetFilters?.[categoryId]?.includes(value) ?? false
86
+ },
87
+ [presetFilters]
80
88
  )
81
89
 
82
- return (
90
+ const activeCount = React.useMemo(() => {
91
+ // Count user-selected filters
92
+ const userCount = Object.values(selectedFilters).reduce(
93
+ (count, selected) => count + selected.length,
94
+ 0
95
+ )
96
+
97
+ // Count active preset filters (those that are in presetFilters AND currently active in selectedFilters)
98
+ let presetCount = 0
99
+ if (presetFilters) {
100
+ for (const [categoryId, presetValues] of Object.entries(presetFilters)) {
101
+ for (const value of presetValues) {
102
+ // Only count if the preset is active (in selectedFilters) but NOT already counted as a user filter
103
+ if (selectedFilters[categoryId]?.includes(value)) {
104
+ // Already counted in userCount, skip
105
+ } else {
106
+ // Not in selectedFilters — it's an inactive preset, don't count
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ return userCount + presetCount
113
+ }, [selectedFilters, presetFilters])
114
+
115
+ /** Collect all preset chips to render */
116
+ const presetChips = React.useMemo(() => {
117
+ if (!presetFilters) return []
118
+
119
+ const chips: { categoryId: string; value: string; label: string; active: boolean }[] = []
120
+
121
+ for (const [categoryId, values] of Object.entries(presetFilters)) {
122
+ const category = categories.find((c) => c.id === categoryId)
123
+ for (const value of values) {
124
+ const option = category?.options.find(
125
+ (opt) => getOptionValue(opt) === value
126
+ )
127
+ const label = option ? getOptionLabel(option) : value
128
+ const active = selectedFilters[categoryId]?.includes(value) ?? false
129
+ chips.push({ categoryId, value, label, active })
130
+ }
131
+ }
132
+
133
+ return chips
134
+ }, [presetFilters, categories, selectedFilters])
135
+
136
+ const triggerButton = (
83
137
  <DropdownMenu>
84
138
  <DropdownMenuTrigger asChild>
85
139
  <Button
@@ -171,6 +225,7 @@ export function DataTableFilter({
171
225
  const value = getOptionValue(option)
172
226
  const label = getOptionLabel(option)
173
227
  const selected = selectedFilters[category.id]?.includes(value) ?? false
228
+ const isPreset = isPresetOption(category.id, value)
174
229
  return (
175
230
  <DropdownMenuItem
176
231
  key={value}
@@ -182,9 +237,15 @@ export function DataTableFilter({
182
237
  >
183
238
  {label}
184
239
  {selected ? (
185
- <span className="text-[10px] font-semibold text-brand-purple">
186
- Applied
187
- </span>
240
+ isPreset ? (
241
+ <span className="text-brand-purple text-[10px] font-semibold">
242
+ {presetLabel}
243
+ </span>
244
+ ) : (
245
+ <span className="text-[10px] font-semibold text-brand-purple">
246
+ Applied
247
+ </span>
248
+ )
188
249
  ) : null}
189
250
  </DropdownMenuItem>
190
251
  )
@@ -208,4 +269,33 @@ export function DataTableFilter({
208
269
  </DropdownMenuContent>
209
270
  </DropdownMenu>
210
271
  )
272
+
273
+ // If there are preset chips, wrap trigger + chips together
274
+ if (presetChips.length > 0) {
275
+ return (
276
+ <div className="flex flex-wrap items-center gap-1.5">
277
+ {triggerButton}
278
+ {presetChips.map((chip) => (
279
+ <button
280
+ key={`${chip.categoryId}-${chip.value}`}
281
+ type="button"
282
+ onClick={() => onTogglePreset?.(chip.categoryId, chip.value)}
283
+ className={cn(
284
+ "inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium transition-colors",
285
+ chip.active
286
+ ? "border-dashed border-brand-purple/30 bg-brand-purple/5 text-brand-purple/80"
287
+ : "border-border/40 bg-transparent text-muted-foreground/60 line-through"
288
+ )}
289
+ >
290
+ <span className="text-brand-purple/50 text-[10px]">
291
+ {presetLabel}:{" "}
292
+ </span>
293
+ {chip.label}
294
+ </button>
295
+ ))}
296
+ </div>
297
+ )
298
+ }
299
+
300
+ return triggerButton
211
301
  }
@@ -242,7 +242,7 @@ export interface EntityMetadataField {
242
242
 
243
243
  export function EntityMetadataGrid({ fields }: { fields: EntityMetadataField[] }) {
244
244
  return (
245
- <div className="grid grid-cols-1 md:grid-cols-[140px_1fr] gap-y-3 gap-x-4 mb-7 text-[13px]">
245
+ <div className="grid grid-cols-1 md:grid-cols-[140px_1fr] gap-y-3 gap-x-4 mb-7 text-[13px] overflow-hidden">
246
246
  {fields.map((field, idx) => (
247
247
  <React.Fragment key={idx}>
248
248
  <div className="flex items-center gap-1.5 text-muted-foreground text-[13px] font-normal">
@@ -13,10 +13,26 @@ import {
13
13
  type ColumnSizingState,
14
14
  type OnChangeFn,
15
15
  } from "@tanstack/react-table"
16
- import { ArrowDown, ArrowUp, ArrowUpDown, SearchX, Loader2 } from "lucide-react"
16
+ import type { RowData } from "@tanstack/react-table"
17
+ import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, EyeOff, Check, SearchX, Loader2 } from "lucide-react"
18
+ import {
19
+ DropdownMenu,
20
+ DropdownMenuContent,
21
+ DropdownMenuTrigger,
22
+ DropdownMenuItem,
23
+ DropdownMenuSeparator,
24
+ } from "./dropdown-menu"
17
25
 
18
26
  import { cn } from "../lib/utils"
19
27
 
28
+ declare module "@tanstack/react-table" {
29
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
30
+ interface ColumnMeta<TData extends RowData, TValue> {
31
+ /** Server-side sort key for this column. Enables sort in the header menu when onColumnSort is also provided. */
32
+ sortKey?: string
33
+ }
34
+ }
35
+
20
36
  export interface VirtualizedDataTableProps<TData> {
21
37
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
38
  columns: ColumnDef<TData, any>[]
@@ -57,6 +73,16 @@ export interface VirtualizedDataTableProps<TData> {
57
73
  emptyMessage?: string
58
74
  emptyDescription?: string
59
75
 
76
+ // Column header menu
77
+ /** Called when user requests sorting from column header. columnId is the column's meta.sortKey. */
78
+ onColumnSort?: (columnId: string, direction: "asc" | "desc") => void
79
+ /** Called when user hides a column from the header menu. */
80
+ onColumnHide?: (columnId: string) => void
81
+ /** The currently active sort column ID — matches a column's meta.sortKey. Used for visual indicators and aria-sort. */
82
+ activeSortColumn?: string | null
83
+ /** The current sort direction. Used for visual indicators and aria-sort. */
84
+ activeSortDirection?: "asc" | "desc"
85
+
60
86
  // Styling
61
87
  className?: string
62
88
  }
@@ -83,6 +109,10 @@ export function VirtualizedDataTable<TData>({
83
109
  onColumnFiltersChange,
84
110
  columnVisibility,
85
111
  onColumnVisibilityChange,
112
+ onColumnSort,
113
+ onColumnHide,
114
+ activeSortColumn,
115
+ activeSortDirection,
86
116
  isLoading,
87
117
  emptyIcon,
88
118
  emptyMessage = "No rows found",
@@ -207,69 +237,150 @@ export function VirtualizedDataTable<TData>({
207
237
  className="flex w-max min-w-full border-b border-border/50"
208
238
  role="row"
209
239
  >
210
- {headerGroup.headers.map((header, colIdx) => (
211
- <div
212
- key={header.id}
213
- className={cn(
214
- "h-9 px-3 flex items-center text-xs font-medium text-muted-foreground whitespace-nowrap relative",
215
- header.column.getCanResize() && "pr-4",
216
- )}
217
- style={{
218
- width: header.getSize(),
219
- minWidth: header.getSize(),
220
- }}
221
- role="columnheader"
222
- aria-colindex={colIdx + 1}
223
- aria-sort={
224
- header.column.getIsSorted() === "asc"
225
- ? "ascending"
226
- : header.column.getIsSorted() === "desc"
227
- ? "descending"
228
- : header.column.getCanSort()
229
- ? "none"
230
- : undefined
240
+ {headerGroup.headers.map((header, colIdx) => {
241
+ const sortKey = header.column.columnDef.meta?.sortKey
242
+ const canServerSort = Boolean(sortKey && onColumnSort)
243
+
244
+ const resolvedAriaSort = (() => {
245
+ if (activeSortColumn !== undefined) {
246
+ // Server-driven
247
+ if (!sortKey) return undefined
248
+ if (activeSortColumn === sortKey) return activeSortDirection === "asc" ? "ascending" as const : "descending" as const
249
+ return "none" as const
231
250
  }
232
- >
233
- {header.isPlaceholder ? null : header.column.getCanSort() ? (
234
- <button
235
- type="button"
236
- className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
237
- onClick={header.column.getToggleSortingHandler()}
238
- >
239
- {flexRender(
240
- header.column.columnDef.header,
241
- header.getContext(),
242
- )}
243
- {header.column.getIsSorted() === "asc" ? (
244
- <ArrowUp className="w-3 h-3" />
245
- ) : header.column.getIsSorted() === "desc" ? (
246
- <ArrowDown className="w-3 h-3" />
247
- ) : (
248
- <ArrowUpDown className="w-3 h-3 opacity-40" />
249
- )}
250
- </button>
251
- ) : (
252
- flexRender(
253
- header.column.columnDef.header,
254
- header.getContext(),
255
- )
256
- )}
257
- {header.column.getCanResize() && (
258
- <div
259
- onMouseDown={header.getResizeHandler()}
260
- onTouchStart={header.getResizeHandler()}
261
- className={cn(
262
- "absolute right-0 top-0 h-full w-3 -mr-1.5 cursor-col-resize select-none touch-none",
263
- "after:absolute after:right-1.5 after:top-0 after:h-full after:w-px",
264
- "after:bg-transparent hover:after:bg-primary/30",
265
- header.column.getIsResizing() && "after:bg-primary/50",
266
- )}
267
- role="separator"
268
- aria-orientation="vertical"
269
- />
270
- )}
271
- </div>
272
- ))}
251
+ // Fallback to TanStack state
252
+ const sorted = header.column.getIsSorted()
253
+ if (sorted === "asc") return "ascending" as const
254
+ if (sorted === "desc") return "descending" as const
255
+ if (header.column.getCanSort()) return "none" as const
256
+ return undefined
257
+ })()
258
+
259
+ const sortIcon = (() => {
260
+ if (!canServerSort) return null
261
+ if (activeSortColumn === sortKey && activeSortDirection === "asc") return <ArrowUp className="w-3 h-3" />
262
+ if (activeSortColumn === sortKey && activeSortDirection === "desc") return <ArrowDown className="w-3 h-3" />
263
+ return <ArrowUpDown className="w-3 h-3 opacity-40" />
264
+ })()
265
+
266
+ const handleHeaderClick = canServerSort ? () => {
267
+ const newDir = activeSortColumn === sortKey
268
+ ? (activeSortDirection === "asc" ? "desc" : "asc")
269
+ : "asc"
270
+ onColumnSort!(sortKey!, newDir)
271
+ } : undefined
272
+
273
+ return (
274
+ <div
275
+ key={header.id}
276
+ className={cn(
277
+ "group/header h-9 px-3 flex items-center text-xs font-medium text-muted-foreground whitespace-nowrap relative",
278
+ header.column.getCanResize() && "pr-4",
279
+ )}
280
+ style={{
281
+ width: header.getSize(),
282
+ minWidth: header.getSize(),
283
+ }}
284
+ role="columnheader"
285
+ aria-colindex={colIdx + 1}
286
+ aria-sort={resolvedAriaSort}
287
+ >
288
+ {header.isPlaceholder ? null : (
289
+ <>
290
+ {canServerSort ? (
291
+ <button
292
+ type="button"
293
+ className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
294
+ onClick={handleHeaderClick}
295
+ >
296
+ {flexRender(header.column.columnDef.header, header.getContext())}
297
+ {sortIcon}
298
+ </button>
299
+ ) : header.column.getCanSort() ? (
300
+ <button
301
+ type="button"
302
+ className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
303
+ onClick={header.column.getToggleSortingHandler()}
304
+ >
305
+ {flexRender(
306
+ header.column.columnDef.header,
307
+ header.getContext(),
308
+ )}
309
+ {header.column.getIsSorted() === "asc" ? (
310
+ <ArrowUp className="w-3 h-3" />
311
+ ) : header.column.getIsSorted() === "desc" ? (
312
+ <ArrowDown className="w-3 h-3" />
313
+ ) : (
314
+ <ArrowUpDown className="w-3 h-3 opacity-40" />
315
+ )}
316
+ </button>
317
+ ) : (
318
+ flexRender(
319
+ header.column.columnDef.header,
320
+ header.getContext(),
321
+ )
322
+ )}
323
+ {(canServerSort || header.column.getCanSort() || header.column.getCanHide()) && (
324
+ <DropdownMenu>
325
+ <DropdownMenuTrigger asChild>
326
+ <button
327
+ type="button"
328
+ className="ml-1 inline-flex items-center hover:text-foreground transition-all opacity-0 group-hover/header:opacity-100"
329
+ aria-label="Column actions"
330
+ >
331
+ <ChevronDown className="w-3 h-3" />
332
+ </button>
333
+ </DropdownMenuTrigger>
334
+ <DropdownMenuContent align="start" className="w-48">
335
+ <DropdownMenuItem
336
+ disabled={!canServerSort}
337
+ onClick={() => canServerSort && onColumnSort!(sortKey!, "asc")}
338
+ >
339
+ <ArrowUp className="w-3.5 h-3.5 mr-2" />
340
+ Sort ascending
341
+ {activeSortColumn === sortKey && activeSortDirection === "asc" && <Check className="w-3.5 h-3.5 ml-auto" />}
342
+ </DropdownMenuItem>
343
+ <DropdownMenuItem
344
+ disabled={!canServerSort}
345
+ onClick={() => canServerSort && onColumnSort!(sortKey!, "desc")}
346
+ >
347
+ <ArrowDown className="w-3.5 h-3.5 mr-2" />
348
+ Sort descending
349
+ {activeSortColumn === sortKey && activeSortDirection === "desc" && <Check className="w-3.5 h-3.5 ml-auto" />}
350
+ </DropdownMenuItem>
351
+ {header.column.getCanHide() && (
352
+ <>
353
+ <DropdownMenuSeparator />
354
+ <DropdownMenuItem
355
+ onClick={() => onColumnHide ? onColumnHide(header.column.id) : header.column.toggleVisibility(false)}
356
+ >
357
+ <EyeOff className="w-3.5 h-3.5 mr-2" />
358
+ Hide column
359
+ </DropdownMenuItem>
360
+ </>
361
+ )}
362
+ </DropdownMenuContent>
363
+ </DropdownMenu>
364
+ )}
365
+ </>
366
+ )}
367
+ {header.column.getCanResize() && (
368
+ <div
369
+ onMouseDown={header.getResizeHandler()}
370
+ onTouchStart={header.getResizeHandler()}
371
+ className={cn(
372
+ "absolute right-0 top-0 h-full w-3 -mr-1.5 cursor-col-resize select-none touch-none",
373
+ "after:absolute after:right-1.5 after:top-0 after:h-full after:w-px",
374
+ "after:bg-transparent hover:after:bg-primary/30",
375
+ header.column.getIsResizing() && "after:bg-primary/50",
376
+ )}
377
+ role="separator"
378
+ aria-orientation="vertical"
379
+ />
380
+ )}
381
+ </div>
382
+ )
383
+ })}
273
384
  </div>
274
385
  ))}
275
386
  </div>
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export * from "./components/avatar"
19
19
  export * from "./components/badge"
20
20
  export * from "./components/button"
21
21
  export * from "./components/card"
22
+ export { CollapsibleSection, type CollapsibleSectionProps } from "./components/collapsible-section"
22
23
  export * from "./components/compliance-badge"
23
24
  export * from "./components/contact-chip"
24
25
  export * from "./components/contact-list"