@handled-ai/design-system 0.15.1 → 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.
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import React from "react"
3
+ import { render, fireEvent } from "@testing-library/react"
4
+ import { DataTableFilter } from "../data-table-filter"
5
+ import { ListFilter } from "lucide-react"
6
+
7
+ const categories = [
8
+ {
9
+ id: "status",
10
+ label: "Status",
11
+ icon: ListFilter,
12
+ options: [
13
+ { value: "open", label: "Open" },
14
+ { value: "closed", label: "Closed" },
15
+ ],
16
+ },
17
+ {
18
+ id: "type",
19
+ label: "Type",
20
+ icon: ListFilter,
21
+ options: [
22
+ { value: "churn", label: "Churn-Risk" },
23
+ { value: "other", label: "Other" },
24
+ ],
25
+ },
26
+ ]
27
+
28
+ describe("DataTableFilter – preset support", () => {
29
+ it("renders preset chips below trigger button", () => {
30
+ const { container } = render(
31
+ <DataTableFilter
32
+ categories={categories}
33
+ selectedFilters={{ status: ["open"] }}
34
+ onToggleFilter={() => {}}
35
+ presetFilters={{ status: ["open"] }}
36
+ onTogglePreset={() => {}}
37
+ />
38
+ )
39
+
40
+ // Should have a wrapper div with flex-wrap
41
+ const wrapper = container.firstElementChild
42
+ expect(wrapper).not.toBeNull()
43
+ expect(wrapper!.className).toContain("flex")
44
+ expect(wrapper!.className).toContain("flex-wrap")
45
+
46
+ // Should have a preset chip button (not the dropdown trigger)
47
+ const buttons = container.querySelectorAll("button")
48
+ // One trigger button + one preset chip
49
+ expect(buttons.length).toBeGreaterThanOrEqual(2)
50
+ })
51
+
52
+ it("preset chips show 'Default:' prefix label", () => {
53
+ const { getByText } = render(
54
+ <DataTableFilter
55
+ categories={categories}
56
+ selectedFilters={{ status: ["open"] }}
57
+ onToggleFilter={() => {}}
58
+ presetFilters={{ status: ["open"] }}
59
+ onTogglePreset={() => {}}
60
+ />
61
+ )
62
+
63
+ // The chip should contain the label text
64
+ expect(getByText("Open")).not.toBeNull()
65
+ })
66
+
67
+ it("preset chips have no X dismiss button", () => {
68
+ const { container } = render(
69
+ <DataTableFilter
70
+ categories={categories}
71
+ selectedFilters={{ status: ["open"] }}
72
+ onToggleFilter={() => {}}
73
+ presetFilters={{ status: ["open"] }}
74
+ onTogglePreset={() => {}}
75
+ />
76
+ )
77
+
78
+ // No X icon — preset chips should not have a dismiss button with aria-label close or X text
79
+ const allButtons = container.querySelectorAll("button")
80
+ for (const btn of allButtons) {
81
+ const text = btn.textContent ?? ""
82
+ // Preset chip buttons should not have just "X" as text content
83
+ if (text.includes("Open") && text.includes("Default")) {
84
+ // This is the preset chip, should not contain an "×" or "X" dismiss icon child
85
+ const xIcon = btn.querySelector('[data-testid="dismiss"]')
86
+ expect(xIcon).toBeNull()
87
+ }
88
+ }
89
+ })
90
+
91
+ it("clicking preset chip calls onTogglePreset", () => {
92
+ const onTogglePreset = vi.fn()
93
+ const { container } = render(
94
+ <DataTableFilter
95
+ categories={categories}
96
+ selectedFilters={{ status: ["open"] }}
97
+ onToggleFilter={() => {}}
98
+ presetFilters={{ status: ["open"] }}
99
+ onTogglePreset={onTogglePreset}
100
+ />
101
+ )
102
+
103
+ // Find the preset chip button (contains "Open" and "Default")
104
+ const buttons = container.querySelectorAll("button")
105
+ const presetChip = Array.from(buttons).find(
106
+ (btn) => btn.textContent?.includes("Open") && btn.textContent?.includes("Default")
107
+ )
108
+ expect(presetChip).not.toBeUndefined()
109
+
110
+ fireEvent.click(presetChip!)
111
+ expect(onTogglePreset).toHaveBeenCalledWith("status", "open")
112
+ })
113
+
114
+ it("deactivated preset chips show muted/line-through style", () => {
115
+ const { container } = render(
116
+ <DataTableFilter
117
+ categories={categories}
118
+ selectedFilters={{}}
119
+ onToggleFilter={() => {}}
120
+ presetFilters={{ status: ["open"] }}
121
+ onTogglePreset={() => {}}
122
+ />
123
+ )
124
+
125
+ // Find the preset chip
126
+ const buttons = container.querySelectorAll("button")
127
+ const presetChip = Array.from(buttons).find(
128
+ (btn) => btn.textContent?.includes("Open") && btn.textContent?.includes("Default")
129
+ )
130
+ expect(presetChip).not.toBeUndefined()
131
+ expect(presetChip!.className).toContain("line-through")
132
+ expect(presetChip!.className).toContain("text-muted-foreground/60")
133
+ })
134
+
135
+ it("active preset chips show brand-purple style", () => {
136
+ const { container } = render(
137
+ <DataTableFilter
138
+ categories={categories}
139
+ selectedFilters={{ status: ["open"] }}
140
+ onToggleFilter={() => {}}
141
+ presetFilters={{ status: ["open"] }}
142
+ onTogglePreset={() => {}}
143
+ />
144
+ )
145
+
146
+ const buttons = container.querySelectorAll("button")
147
+ const presetChip = Array.from(buttons).find(
148
+ (btn) => btn.textContent?.includes("Open") && btn.textContent?.includes("Default")
149
+ )
150
+ expect(presetChip).not.toBeUndefined()
151
+ expect(presetChip!.className).toContain("border-brand-purple/30")
152
+ expect(presetChip!.className).toContain("bg-brand-purple/5")
153
+ expect(presetChip!.className).not.toContain("line-through")
154
+ })
155
+
156
+ it("uses custom presetLabel", () => {
157
+ const { getByText } = render(
158
+ <DataTableFilter
159
+ categories={categories}
160
+ selectedFilters={{ status: ["open"] }}
161
+ onToggleFilter={() => {}}
162
+ presetFilters={{ status: ["open"] }}
163
+ onTogglePreset={() => {}}
164
+ presetLabel="Preset"
165
+ />
166
+ )
167
+
168
+ // The label prefix should show "Preset: "
169
+ const presetPrefix = getByText(/Preset:/)
170
+ expect(presetPrefix).not.toBeNull()
171
+ })
172
+
173
+ it("renders multiple preset chips for multiple categories", () => {
174
+ const { container } = render(
175
+ <DataTableFilter
176
+ categories={categories}
177
+ selectedFilters={{ status: ["open"], type: ["churn"] }}
178
+ onToggleFilter={() => {}}
179
+ presetFilters={{ status: ["open"], type: ["churn"] }}
180
+ onTogglePreset={() => {}}
181
+ />
182
+ )
183
+
184
+ const buttons = container.querySelectorAll("button")
185
+ const presetChips = Array.from(buttons).filter(
186
+ (btn) => btn.textContent?.includes("Default")
187
+ )
188
+ // Two preset chips (one for status:open, one for type:churn)
189
+ expect(presetChips.length).toBe(2)
190
+ })
191
+
192
+ it("does not render wrapper div when no preset filters", () => {
193
+ const { container } = render(
194
+ <DataTableFilter
195
+ categories={categories}
196
+ selectedFilters={{}}
197
+ onToggleFilter={() => {}}
198
+ />
199
+ )
200
+
201
+ // Without presets, the root should be the DropdownMenu
202
+ // which doesn't have flex-wrap class
203
+ const wrapper = container.firstElementChild
204
+ // The button should be the first interactive child (DropdownMenu renders button)
205
+ const button = container.querySelector("button")
206
+ expect(button).not.toBeNull()
207
+ expect(button!.textContent).toContain("Filter")
208
+ })
209
+ })
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import React from "react"
3
+ import { render } from "@testing-library/react"
4
+ import { EntityMetadataGrid } from "../entity-panel"
5
+ import { CalendarDays } from "lucide-react"
6
+
7
+ describe("EntityMetadataGrid", () => {
8
+ it("has overflow-hidden on the grid container", () => {
9
+ const { container } = render(
10
+ <EntityMetadataGrid
11
+ fields={[
12
+ {
13
+ icon: CalendarDays,
14
+ label: "Test",
15
+ value: "Some value",
16
+ },
17
+ ]}
18
+ />
19
+ )
20
+
21
+ const grid = container.firstElementChild
22
+ expect(grid).not.toBeNull()
23
+ expect(grid!.className).toContain("overflow-hidden")
24
+ })
25
+ })
@@ -414,7 +414,6 @@ describe("VirtualizedDataTable — consistent header styling", () => {
414
414
  />,
415
415
  );
416
416
 
417
- const headers = container.querySelectorAll('[role="columnheader"]');
418
417
  // Get the sort buttons (not the dropdown triggers)
419
418
  const sortButtons = Array.from(
420
419
  container.querySelectorAll('[role="columnheader"] button'),
@@ -0,0 +1,62 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ChevronDown } from "lucide-react"
5
+ import { cn } from "../lib/utils"
6
+
7
+ export interface CollapsibleSectionProps {
8
+ /** Total number of items (used in the expansion bar label). */
9
+ count: number
10
+ /** Items to show before collapsing. Default: 5. */
11
+ maxItems?: number
12
+ /** Children to render — the component slices React.Children.toArray(children) at maxItems. */
13
+ children: React.ReactNode
14
+ /** Start expanded. Default: false. */
15
+ defaultExpanded?: boolean
16
+ /** Custom label for the expansion bar. Default: "Show all {count}". */
17
+ expandLabel?: string
18
+ /** Custom label when expanded. Default: "Show less". */
19
+ collapseLabel?: string
20
+ className?: string
21
+ }
22
+
23
+ export function CollapsibleSection({
24
+ count,
25
+ maxItems = 5,
26
+ children,
27
+ defaultExpanded = false,
28
+ expandLabel,
29
+ collapseLabel,
30
+ className,
31
+ }: CollapsibleSectionProps) {
32
+ const [expanded, setExpanded] = React.useState(defaultExpanded)
33
+
34
+ const items = React.Children.toArray(children)
35
+ const visible = expanded ? items : items.slice(0, maxItems)
36
+ const showBar = items.length > maxItems
37
+
38
+ return (
39
+ <div className={className}>
40
+ {visible}
41
+ {showBar && (
42
+ <button
43
+ type="button"
44
+ onClick={() => setExpanded(!expanded)}
45
+ className="flex items-center justify-between w-full px-3 py-1.5 mt-1 text-xs font-medium text-muted-foreground border border-border/50 rounded-md hover:bg-muted/30 transition-colors cursor-pointer"
46
+ >
47
+ <span>
48
+ {expanded
49
+ ? (collapseLabel ?? "Show less")
50
+ : (expandLabel ?? `Show all ${count}`)}
51
+ </span>
52
+ <ChevronDown
53
+ className={cn(
54
+ "h-3.5 w-3.5 transition-transform duration-200",
55
+ expanded && "rotate-180"
56
+ )}
57
+ />
58
+ </button>
59
+ )}
60
+ </div>
61
+ )
62
+ }
@@ -1,7 +1,8 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { Plus, X } from "lucide-react"
4
+ import { ChevronDown, Plus, X } from "lucide-react"
5
+ import { cn } from "../lib/utils"
5
6
  import { Badge } from "./badge"
6
7
  import { Button } from "./button"
7
8
 
@@ -35,6 +36,8 @@ export interface ContactListProps {
35
36
  contacts: ContactItem[]
36
37
  onAdd?: () => void
37
38
  addLabel?: string
39
+ /** Maximum contacts to show before collapsing. Shows expansion bar when exceeded. Undefined = show all (backward compatible). */
40
+ maxItems?: number
38
41
  }
39
42
 
40
43
  const badgeColors: Record<string, string> = {
@@ -96,7 +99,12 @@ function ContactRow({ contact }: { contact: ContactItem }) {
96
99
  )
97
100
  }
98
101
 
99
- export function ContactList({ title, count, contacts, onAdd, addLabel }: ContactListProps) {
102
+ export function ContactList({ title, count, contacts, onAdd, addLabel, maxItems }: ContactListProps) {
103
+ const [expanded, setExpanded] = React.useState(false)
104
+
105
+ const visibleContacts = maxItems != null && !expanded ? contacts.slice(0, maxItems) : contacts
106
+ const showExpansionBar = maxItems != null && contacts.length > maxItems
107
+
100
108
  return (
101
109
  <div className="space-y-2.5">
102
110
  <div className="flex items-center justify-between">
@@ -112,10 +120,21 @@ export function ContactList({ title, count, contacts, onAdd, addLabel }: Contact
112
120
  </div>
113
121
 
114
122
  <div className="space-y-0">
115
- {contacts.map((contact) => (
123
+ {visibleContacts.map((contact) => (
116
124
  <ContactRow key={contact.id} contact={contact} />
117
125
  ))}
118
126
  </div>
127
+
128
+ {showExpansionBar && (
129
+ <button
130
+ type="button"
131
+ onClick={() => setExpanded(!expanded)}
132
+ className="flex items-center justify-between w-full px-3 py-1.5 mt-1 text-xs font-medium text-muted-foreground border border-border/50 rounded-md hover:bg-muted/30 transition-colors"
133
+ >
134
+ <span>{expanded ? "Show less" : `Show all ${contacts.length} contacts`}</span>
135
+ <ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180")} />
136
+ </button>
137
+ )}
119
138
  </div>
120
139
  )
121
140
  }
@@ -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">
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"