@handled-ai/design-system 0.15.1 → 0.16.1
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/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- 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-condition-filter.d.ts +37 -0
- package/dist/components/data-table-condition-filter.js +407 -0
- package/dist/components/data-table-condition-filter.js.map +1 -0
- package/dist/components/data-table-filter.d.ts +19 -2
- package/dist/components/data-table-filter.js +160 -13
- package/dist/components/data-table-filter.js.map +1 -1
- package/dist/components/data-table-toolbar.d.ts +1 -0
- package/dist/components/data-table.d.ts +1 -0
- package/dist/components/entity-panel.js +1 -1
- package/dist/components/entity-panel.js.map +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/prototype/index.d.ts +1 -0
- package/dist/prototype/prototype-accounts-view.d.ts +1 -0
- package/dist/prototype/prototype-admin-view.d.ts +1 -0
- package/dist/prototype/prototype-config.d.ts +1 -0
- package/dist/prototype/prototype-inbox-view.d.ts +4 -1
- package/dist/prototype/prototype-inbox-view.js +6 -1
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +1 -0
- package/dist/prototype/prototype-shell.d.ts +1 -0
- 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-condition-filter.test.tsx +397 -0
- package/src/components/__tests__/data-table-filter-presets.test.tsx +209 -0
- package/src/components/__tests__/data-table-filter.test.tsx +270 -3
- 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-condition-filter.tsx +513 -0
- package/src/components/data-table-filter.tsx +201 -13
- package/src/components/entity-panel.tsx +1 -1
- package/src/index.ts +2 -0
- package/src/prototype/__tests__/detail-view-attention.test.tsx +101 -0
- package/src/prototype/prototype-inbox-view.tsx +8 -0
|
@@ -1,9 +1,80 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
import React from "react";
|
|
3
|
-
import { render } from "@testing-library/react";
|
|
4
|
-
import { DataTableFilter } from "../data-table-filter";
|
|
3
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
5
4
|
import { ListFilter } from "lucide-react";
|
|
6
5
|
|
|
6
|
+
import { DataTableFilter } from "../data-table-filter";
|
|
7
|
+
import type { DataTableFilterCategory } from "../data-table-filter";
|
|
8
|
+
import type { ConditionFieldDef } from "../data-table-condition-filter";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Radix DropdownMenu renders content inside a portal which makes it
|
|
12
|
+
* difficult to test in happy-dom. We mock the DropdownMenu primitives
|
|
13
|
+
* to render children inline so we can assert on the component's
|
|
14
|
+
* rendering logic directly.
|
|
15
|
+
*/
|
|
16
|
+
vi.mock("../dropdown-menu", async () => {
|
|
17
|
+
const ReactMod = await import("react");
|
|
18
|
+
|
|
19
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
20
|
+
const DropdownMenu = ({ children, ...props }: any) =>
|
|
21
|
+
ReactMod.createElement("div", { "data-slot": "dropdown-menu", ...props }, children);
|
|
22
|
+
const DropdownMenuTrigger = ({ children, asChild, ...props }: any) => {
|
|
23
|
+
if (asChild && ReactMod.isValidElement(children)) {
|
|
24
|
+
return ReactMod.cloneElement(children, { "data-slot": "dropdown-menu-trigger", ...props } as any);
|
|
25
|
+
}
|
|
26
|
+
return ReactMod.createElement("button", { "data-slot": "dropdown-menu-trigger", ...props }, children);
|
|
27
|
+
};
|
|
28
|
+
const DropdownMenuContent = ({ children, ...props }: any) =>
|
|
29
|
+
ReactMod.createElement("div", { "data-slot": "dropdown-menu-content", ...props }, children);
|
|
30
|
+
const DropdownMenuItem = ({ children, onSelect, className, ...props }: any) =>
|
|
31
|
+
ReactMod.createElement(
|
|
32
|
+
"div",
|
|
33
|
+
{
|
|
34
|
+
"data-slot": "dropdown-menu-item",
|
|
35
|
+
role: "menuitem",
|
|
36
|
+
className,
|
|
37
|
+
onClick: (e: any) => onSelect?.(e),
|
|
38
|
+
...props,
|
|
39
|
+
},
|
|
40
|
+
children
|
|
41
|
+
);
|
|
42
|
+
const DropdownMenuSub = ({ children, ...props }: any) =>
|
|
43
|
+
ReactMod.createElement("div", { "data-slot": "dropdown-menu-sub", ...props }, children);
|
|
44
|
+
const DropdownMenuSubTrigger = ({ children, className, ...props }: any) =>
|
|
45
|
+
ReactMod.createElement("div", { "data-slot": "dropdown-menu-sub-trigger", role: "menuitem", className, ...props }, children);
|
|
46
|
+
const DropdownMenuSubContent = ({ children, ...props }: any) =>
|
|
47
|
+
ReactMod.createElement("div", { "data-slot": "dropdown-menu-sub-content", ...props }, children);
|
|
48
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
DropdownMenu,
|
|
52
|
+
DropdownMenuTrigger,
|
|
53
|
+
DropdownMenuContent,
|
|
54
|
+
DropdownMenuItem,
|
|
55
|
+
DropdownMenuSub,
|
|
56
|
+
DropdownMenuSubTrigger,
|
|
57
|
+
DropdownMenuSubContent,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
vi.mock("radix-ui", async (importOriginal) => {
|
|
62
|
+
const actual = await importOriginal<typeof import("radix-ui")>();
|
|
63
|
+
const ReactMod = await import("react");
|
|
64
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
65
|
+
const Root = ({ children }: any) => ReactMod.createElement("div", { "data-slot": "popover-root" }, children);
|
|
66
|
+
const Trigger = ({ children, asChild, ...props }: any) => {
|
|
67
|
+
if (asChild && ReactMod.isValidElement(children)) {
|
|
68
|
+
return ReactMod.cloneElement(children, { "data-slot": "popover-trigger", ...props } as any);
|
|
69
|
+
}
|
|
70
|
+
return ReactMod.createElement("button", { "data-slot": "popover-trigger", ...props }, children);
|
|
71
|
+
};
|
|
72
|
+
const Portal = ({ children }: any) => ReactMod.createElement(ReactMod.Fragment, null, children);
|
|
73
|
+
const Content = ({ children, ...props }: any) => ReactMod.createElement("div", { "data-slot": "popover-content", ...props }, children);
|
|
74
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
75
|
+
return { ...actual, Popover: { Root, Trigger, Portal, Content } };
|
|
76
|
+
});
|
|
77
|
+
|
|
7
78
|
const defaultProps = {
|
|
8
79
|
categories: [
|
|
9
80
|
{
|
|
@@ -38,4 +109,200 @@ describe("DataTableFilter", () => {
|
|
|
38
109
|
expect(button!.className).toContain("text-primary-foreground");
|
|
39
110
|
expect(button!.className).toContain("h-8");
|
|
40
111
|
});
|
|
112
|
+
|
|
113
|
+
it("renders boolean category as direct toggle row", () => {
|
|
114
|
+
const onToggle = vi.fn();
|
|
115
|
+
const boolCategory: DataTableFilterCategory = {
|
|
116
|
+
id: "archived",
|
|
117
|
+
label: "Archived",
|
|
118
|
+
icon: ListFilter,
|
|
119
|
+
options: [],
|
|
120
|
+
type: "boolean",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
render(
|
|
124
|
+
<DataTableFilter
|
|
125
|
+
categories={[boolCategory]}
|
|
126
|
+
selectedFilters={{}}
|
|
127
|
+
onToggleFilter={onToggle}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const item = screen.getByText("Archived").closest('[data-slot="dropdown-menu-item"]');
|
|
132
|
+
expect(item).not.toBeNull();
|
|
133
|
+
expect(document.querySelector('[data-slot="dropdown-menu-sub-trigger"]')).toBeNull();
|
|
134
|
+
|
|
135
|
+
fireEvent.click(item!);
|
|
136
|
+
expect(onToggle).toHaveBeenCalledWith("archived", "true");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("shows check icon on active boolean category", () => {
|
|
140
|
+
const boolCategory: DataTableFilterCategory = {
|
|
141
|
+
id: "archived",
|
|
142
|
+
label: "Archived",
|
|
143
|
+
icon: ListFilter,
|
|
144
|
+
options: [],
|
|
145
|
+
type: "boolean",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
render(
|
|
149
|
+
<DataTableFilter
|
|
150
|
+
categories={[boolCategory]}
|
|
151
|
+
selectedFilters={{ archived: ["true"] }}
|
|
152
|
+
onToggleFilter={() => {}}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const item = screen.getByText("Archived").closest('[data-slot="dropdown-menu-item"]');
|
|
157
|
+
expect(item).not.toBeNull();
|
|
158
|
+
expect(item!.className).toContain("text-brand-purple");
|
|
159
|
+
expect(item!.querySelector("svg.ml-auto")).not.toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("renders single-select category with submenu", () => {
|
|
163
|
+
const singleCategory: DataTableFilterCategory = {
|
|
164
|
+
id: "priority",
|
|
165
|
+
label: "Priority",
|
|
166
|
+
icon: ListFilter,
|
|
167
|
+
options: ["High", "Medium", "Low"],
|
|
168
|
+
type: "single",
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<DataTableFilter
|
|
173
|
+
categories={[singleCategory]}
|
|
174
|
+
selectedFilters={{}}
|
|
175
|
+
onToggleFilter={() => {}}
|
|
176
|
+
/>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const subTrigger = document.querySelector('[data-slot="dropdown-menu-sub-trigger"]');
|
|
180
|
+
expect(subTrigger).not.toBeNull();
|
|
181
|
+
expect(subTrigger!.textContent).toContain("Priority");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("shows filled circle indicator for selected single-select option", () => {
|
|
185
|
+
const singleCategory: DataTableFilterCategory = {
|
|
186
|
+
id: "priority",
|
|
187
|
+
label: "Priority",
|
|
188
|
+
icon: ListFilter,
|
|
189
|
+
options: ["High", "Medium", "Low"],
|
|
190
|
+
type: "single",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
render(
|
|
194
|
+
<DataTableFilter
|
|
195
|
+
categories={[singleCategory]}
|
|
196
|
+
selectedFilters={{ priority: ["High"] }}
|
|
197
|
+
onToggleFilter={() => {}}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const highItem = screen.getByText("High").closest('[data-slot="dropdown-menu-item"]');
|
|
202
|
+
expect(highItem).not.toBeNull();
|
|
203
|
+
expect(highItem!.querySelector("span.rounded-full")).not.toBeNull();
|
|
204
|
+
expect(highItem!.textContent).not.toContain("Applied");
|
|
205
|
+
|
|
206
|
+
const mediumItem = screen.getByText("Medium").closest('[data-slot="dropdown-menu-item"]');
|
|
207
|
+
expect(mediumItem).not.toBeNull();
|
|
208
|
+
expect(mediumItem!.querySelector("span.rounded-full")).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("boolean category keeps dropdown open on click", () => {
|
|
212
|
+
const onToggle = vi.fn();
|
|
213
|
+
const boolCategory: DataTableFilterCategory = {
|
|
214
|
+
id: "archived",
|
|
215
|
+
label: "Archived",
|
|
216
|
+
icon: ListFilter,
|
|
217
|
+
options: [],
|
|
218
|
+
type: "boolean",
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
render(
|
|
222
|
+
<DataTableFilter
|
|
223
|
+
categories={[boolCategory]}
|
|
224
|
+
selectedFilters={{}}
|
|
225
|
+
onToggleFilter={onToggle}
|
|
226
|
+
/>
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const item = screen.getByText("Archived").closest('[data-slot="dropdown-menu-item"]');
|
|
230
|
+
expect(item).not.toBeNull();
|
|
231
|
+
|
|
232
|
+
const preventDefaultSpy = vi.fn();
|
|
233
|
+
const mockEvent = new MouseEvent("click", { bubbles: true });
|
|
234
|
+
Object.defineProperty(mockEvent, "preventDefault", { value: preventDefaultSpy });
|
|
235
|
+
item!.dispatchEvent(mockEvent);
|
|
236
|
+
|
|
237
|
+
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
238
|
+
expect(onToggle).toHaveBeenCalledWith("archived", "true");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("multi-select category still works as before", () => {
|
|
242
|
+
const multiCategory: DataTableFilterCategory = {
|
|
243
|
+
id: "status",
|
|
244
|
+
label: "Status",
|
|
245
|
+
icon: ListFilter,
|
|
246
|
+
options: ["Open", "Closed"],
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
render(
|
|
250
|
+
<DataTableFilter
|
|
251
|
+
categories={[multiCategory]}
|
|
252
|
+
selectedFilters={{ status: ["Open"] }}
|
|
253
|
+
onToggleFilter={() => {}}
|
|
254
|
+
/>
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const subTrigger = document.querySelector('[data-slot="dropdown-menu-sub-trigger"]');
|
|
258
|
+
expect(subTrigger).not.toBeNull();
|
|
259
|
+
expect(subTrigger!.textContent).toContain("Status");
|
|
260
|
+
|
|
261
|
+
const openItem = screen.getByText("Open").closest('[data-slot="dropdown-menu-item"]');
|
|
262
|
+
expect(openItem).not.toBeNull();
|
|
263
|
+
expect(openItem!.textContent).toContain("Applied");
|
|
264
|
+
expect(openItem!.querySelector("span.rounded-full")).toBeNull();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("does not expose the condition builder entry point without condition fields", () => {
|
|
268
|
+
render(<DataTableFilter {...defaultProps} />);
|
|
269
|
+
|
|
270
|
+
expect(screen.queryByText("Add filter")).toBeNull();
|
|
271
|
+
expect(document.querySelector('[data-slot="condition-filter"]')).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("exposes a condition builder popover entry point when condition fields are provided", () => {
|
|
275
|
+
const conditionFields: ConditionFieldDef[] = [
|
|
276
|
+
{ id: "balance", label: "Account Balance", type: "currency" },
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
render(
|
|
280
|
+
<DataTableFilter
|
|
281
|
+
{...defaultProps}
|
|
282
|
+
conditionFields={conditionFields}
|
|
283
|
+
conditionFilters={[
|
|
284
|
+
{ id: "c-1", field: "balance", operator: "gt", value: 1000 },
|
|
285
|
+
]}
|
|
286
|
+
onConditionFiltersChange={() => {}}
|
|
287
|
+
/>
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const builderEntry = document.querySelector('[data-slot="popover-trigger"]');
|
|
291
|
+
expect(builderEntry).not.toBeNull();
|
|
292
|
+
expect(builderEntry!.textContent).toContain("1");
|
|
293
|
+
expect(document.querySelector('[data-slot="condition-filter"]')).not.toBeNull();
|
|
294
|
+
expect(screen.getByText("Filter builder")).toBeDefined();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("uses the custom condition builder label when provided", () => {
|
|
298
|
+
render(
|
|
299
|
+
<DataTableFilter
|
|
300
|
+
{...defaultProps}
|
|
301
|
+
conditionFields={[{ id: "name", label: "Name", type: "text" }]}
|
|
302
|
+
conditionBuilderLabel="Advanced filters"
|
|
303
|
+
/>
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(screen.getByText("Advanced filters")).toBeDefined();
|
|
307
|
+
});
|
|
41
308
|
});
|
|
@@ -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
|
}
|