@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.
- package/dist/components/collapsible-section.d.ts +20 -0
- package/dist/components/collapsible-section.js +48 -0
- package/dist/components/collapsible-section.js.map +1 -0
- package/dist/components/contact-list.d.ts +3 -1
- package/dist/components/contact-list.js +20 -3
- package/dist/components/contact-list.js.map +1 -1
- package/dist/components/data-table-filter.d.ts +8 -2
- package/dist/components/data-table-filter.js +73 -8
- package/dist/components/data-table-filter.js.map +1 -1
- package/dist/components/entity-panel.js +1 -1
- package/dist/components/entity-panel.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/collapsible-section.test.tsx +143 -0
- package/src/components/__tests__/contact-list.test.tsx +116 -0
- package/src/components/__tests__/data-table-filter-presets.test.tsx +209 -0
- package/src/components/__tests__/entity-metadata-grid.test.tsx +25 -0
- package/src/components/__tests__/virtualized-data-table.test.tsx +0 -1
- package/src/components/collapsible-section.tsx +62 -0
- package/src/components/contact-list.tsx +22 -3
- package/src/components/data-table-filter.tsx +102 -12
- package/src/components/entity-panel.tsx +1 -1
- package/src/index.ts +1 -0
|
@@ -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
|
-
{
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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"
|