@handled-ai/design-system 0.14.7 → 0.14.10

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.
@@ -15,11 +15,23 @@ import {
15
15
  DropdownMenuTrigger,
16
16
  } from "./dropdown-menu"
17
17
 
18
+ export interface FilterOption {
19
+ label: string
20
+ value: string
21
+ }
22
+
18
23
  export interface DataTableFilterCategory {
19
24
  id: string
20
25
  label: string
21
26
  icon: React.ComponentType<{ className?: string }>
22
- options: string[]
27
+ options: (string | FilterOption)[]
28
+ }
29
+
30
+ function getOptionValue(option: string | FilterOption): string {
31
+ return typeof option === "string" ? option : option.value
32
+ }
33
+ function getOptionLabel(option: string | FilterOption): string {
34
+ return typeof option === "string" ? option : option.label
23
35
  }
24
36
 
25
37
  interface DataTableFilterProps {
@@ -27,6 +39,8 @@ interface DataTableFilterProps {
27
39
  selectedFilters: Record<string, string[]>
28
40
  onToggleFilter: (categoryId: string, option: string) => void
29
41
  className?: string
42
+ /** Minimum number of options before showing the sub-menu search input. Defaults to 8. */
43
+ optionSearchThreshold?: number
30
44
  }
31
45
 
32
46
  export function DataTableFilter({
@@ -34,8 +48,10 @@ export function DataTableFilter({
34
48
  selectedFilters,
35
49
  onToggleFilter,
36
50
  className,
51
+ optionSearchThreshold = 8,
37
52
  }: DataTableFilterProps) {
38
53
  const [query, setQuery] = React.useState("")
54
+ const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})
39
55
 
40
56
  const visibleCategories = React.useMemo(() => {
41
57
  const normalized = query.trim().toLowerCase()
@@ -49,7 +65,7 @@ export function DataTableFilter({
49
65
  }
50
66
 
51
67
  return category.options.some((option) =>
52
- option.toLowerCase().includes(normalized)
68
+ getOptionLabel(option).toLowerCase().includes(normalized)
53
69
  )
54
70
  })
55
71
  }, [categories, query])
@@ -99,38 +115,89 @@ export function DataTableFilter({
99
115
  </div>
100
116
 
101
117
  <div className="max-h-[320px] overflow-y-auto p-1">
102
- {visibleCategories.map((category) => (
103
- <DropdownMenuSub key={category.id}>
104
- <DropdownMenuSubTrigger className="cursor-pointer py-1.5 text-xs">
105
- <category.icon className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
106
- {category.label}
107
- </DropdownMenuSubTrigger>
108
- <DropdownMenuSubContent className="w-52 p-1">
109
- {category.options.map((option) => {
110
- const selected =
111
- selectedFilters[category.id]?.includes(option) ?? false
118
+ {visibleCategories.map((category) => {
119
+ const subQuery = (subQueries[category.id] ?? "").trim().toLowerCase()
120
+ const filteredOptions = subQuery
121
+ ? category.options.filter((opt) =>
122
+ getOptionLabel(opt).toLowerCase().includes(subQuery)
123
+ )
124
+ : category.options
112
125
 
113
- return (
114
- <DropdownMenuItem
115
- key={option}
116
- className="cursor-pointer justify-between text-xs"
117
- onSelect={(event) => {
118
- event.preventDefault()
119
- onToggleFilter(category.id, option)
120
- }}
121
- >
122
- {option}
123
- {selected ? (
124
- <span className="text-[10px] font-semibold text-brand-purple">
125
- Applied
126
- </span>
127
- ) : null}
128
- </DropdownMenuItem>
129
- )
130
- })}
131
- </DropdownMenuSubContent>
132
- </DropdownMenuSub>
133
- ))}
126
+ return (
127
+ <DropdownMenuSub
128
+ key={category.id}
129
+ onOpenChange={(open) => {
130
+ if (!open) {
131
+ setSubQueries((prev) => {
132
+ const next = { ...prev }
133
+ delete next[category.id]
134
+ return next
135
+ })
136
+ }
137
+ }}
138
+ >
139
+ <DropdownMenuSubTrigger className="cursor-pointer py-1.5 text-xs">
140
+ <category.icon className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
141
+ {category.label}
142
+ </DropdownMenuSubTrigger>
143
+ <DropdownMenuSubContent className="max-h-[320px] w-52 overflow-y-auto p-1">
144
+ {/* Submenu search — only for categories with many options */}
145
+ {category.options.length > optionSearchThreshold && (
146
+ <div className="sticky top-0 z-10 border-b border-border bg-popover p-1.5">
147
+ <div className="relative">
148
+ <Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
149
+ <input
150
+ 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"
151
+ placeholder="Search..."
152
+ value={subQueries[category.id] ?? ""}
153
+ onChange={(e) =>
154
+ setSubQueries((prev) => ({ ...prev, [category.id]: e.target.value }))
155
+ }
156
+ onClick={(e) => e.stopPropagation()}
157
+ onKeyDown={(e) => {
158
+ // Allow navigation keys to propagate to Radix menu handling
159
+ // so keyboard users can move to and select filtered options.
160
+ const navKeys = ["ArrowDown", "ArrowUp", "Enter", "Escape", "Tab"]
161
+ if (!navKeys.includes(e.key)) {
162
+ e.stopPropagation()
163
+ }
164
+ }}
165
+ />
166
+ </div>
167
+ </div>
168
+ )}
169
+ {/* Filtered options */}
170
+ {filteredOptions.map((option) => {
171
+ const value = getOptionValue(option)
172
+ const label = getOptionLabel(option)
173
+ const selected = selectedFilters[category.id]?.includes(value) ?? false
174
+ return (
175
+ <DropdownMenuItem
176
+ key={value}
177
+ className="cursor-pointer justify-between text-xs"
178
+ onSelect={(event) => {
179
+ event.preventDefault()
180
+ onToggleFilter(category.id, value)
181
+ }}
182
+ >
183
+ {label}
184
+ {selected ? (
185
+ <span className="text-[10px] font-semibold text-brand-purple">
186
+ Applied
187
+ </span>
188
+ ) : null}
189
+ </DropdownMenuItem>
190
+ )
191
+ })}
192
+ {filteredOptions.length === 0 && category.options.length > 0 && (
193
+ <div className="p-2 text-center text-xs text-muted-foreground">
194
+ No matches
195
+ </div>
196
+ )}
197
+ </DropdownMenuSubContent>
198
+ </DropdownMenuSub>
199
+ )
200
+ })}
134
201
 
135
202
  {visibleCategories.length === 0 ? (
136
203
  <div className="p-2 text-center text-xs text-muted-foreground">
@@ -49,6 +49,7 @@ export type DataRow = {
49
49
  headcount: string
50
50
  lastFunding: string
51
51
  owner: string
52
+ ownerEmail?: string
52
53
  opportunityCount: number
53
54
  productAdoptionScore: number
54
55
  sourceSystem?: string
@@ -477,7 +478,7 @@ function isRowMatchingCategoryFilter(
477
478
  case "lastFunding":
478
479
  return options.includes(row.lastFunding)
479
480
  case "owner":
480
- return options.includes(row.owner)
481
+ return options.includes(row.ownerEmail ?? row.owner)
481
482
  case "opportunityCount":
482
483
  return options.some((option) => {
483
484
  if (option === "3+") {
@@ -10,6 +10,7 @@ import {
10
10
  type SortingState,
11
11
  type ColumnFiltersState,
12
12
  type VisibilityState,
13
+ type ColumnSizingState,
13
14
  type OnChangeFn,
14
15
  } from "@tanstack/react-table"
15
16
  import { ArrowDown, ArrowUp, ArrowUpDown, SearchX, Loader2 } from "lucide-react"
@@ -36,6 +37,12 @@ export interface VirtualizedDataTableProps<TData> {
36
37
  hasMore?: boolean
37
38
  isFetchingMore?: boolean
38
39
 
40
+ // Column resizing
41
+ enableColumnResizing?: boolean
42
+ columnResizeMode?: "onChange" | "onEnd"
43
+ columnSizing?: ColumnSizingState
44
+ onColumnSizingChange?: OnChangeFn<ColumnSizingState>
45
+
39
46
  // Server-driven state (controlled) — omit for internal state
40
47
  sorting?: SortingState
41
48
  onSortingChange?: OnChangeFn<SortingState>
@@ -66,6 +73,10 @@ export function VirtualizedDataTable<TData>({
66
73
  reachBottomThreshold = 5,
67
74
  hasMore = true,
68
75
  isFetchingMore,
76
+ enableColumnResizing = false,
77
+ columnResizeMode = "onEnd",
78
+ columnSizing,
79
+ onColumnSizingChange,
69
80
  sorting,
70
81
  onSortingChange,
71
82
  columnFilters,
@@ -97,6 +108,13 @@ export function VirtualizedDataTable<TData>({
97
108
  const resolvedOnColumnVisibilityChange =
98
109
  onColumnVisibilityChange ?? setInternalColumnVisibility
99
110
 
111
+ // Controlled/uncontrolled state for column sizing
112
+ const [internalColumnSizing, setInternalColumnSizing] =
113
+ React.useState<ColumnSizingState>({})
114
+ const resolvedColumnSizing = columnSizing ?? internalColumnSizing
115
+ const resolvedOnColumnSizingChange =
116
+ onColumnSizingChange ?? setInternalColumnSizing
117
+
100
118
  // TanStack Table setup
101
119
  const table = useReactTable({
102
120
  data,
@@ -106,10 +124,14 @@ export function VirtualizedDataTable<TData>({
106
124
  sorting: resolvedSorting,
107
125
  columnFilters: resolvedColumnFilters,
108
126
  columnVisibility: resolvedColumnVisibility,
127
+ columnSizing: resolvedColumnSizing,
109
128
  },
110
129
  onSortingChange: resolvedOnSortingChange,
111
130
  onColumnFiltersChange: resolvedOnColumnFiltersChange,
112
131
  onColumnVisibilityChange: resolvedOnColumnVisibilityChange,
132
+ onColumnSizingChange: resolvedOnColumnSizingChange,
133
+ enableColumnResizing,
134
+ columnResizeMode,
113
135
  manualSorting: true,
114
136
  manualFiltering: true,
115
137
  manualPagination: true,
@@ -188,7 +210,10 @@ export function VirtualizedDataTable<TData>({
188
210
  {headerGroup.headers.map((header, colIdx) => (
189
211
  <div
190
212
  key={header.id}
191
- className="h-9 px-3 flex items-center text-xs font-medium text-muted-foreground whitespace-nowrap"
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
+ )}
192
217
  style={{
193
218
  width: header.getSize(),
194
219
  minWidth: header.getSize(),
@@ -229,6 +254,20 @@ export function VirtualizedDataTable<TData>({
229
254
  header.getContext(),
230
255
  )
231
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
+ )}
232
271
  </div>
233
272
  ))}
234
273
  </div>
package/src/index.ts CHANGED
@@ -91,6 +91,7 @@ export * from "./components/tooltip"
91
91
  export * from "./components/variable-autocomplete"
92
92
  export * from "./components/view-mode-toggle"
93
93
  export * from "./components/virtualized-data-table"
94
+ export type { ColumnSizingState } from "@tanstack/react-table"
94
95
 
95
96
  // Charts (re-exported for backward compatibility with root imports)
96
97
  export * from "./charts/index"