@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.
- 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/components/virtualized-data-table.d.ts +16 -2
- package/dist/components/virtualized-data-table.js +153 -52
- package/dist/components/virtualized-data-table.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 +556 -0
- 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/components/virtualized-data-table.tsx +174 -63
- 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
|
-
|
|
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">
|
|
@@ -13,10 +13,26 @@ import {
|
|
|
13
13
|
type ColumnSizingState,
|
|
14
14
|
type OnChangeFn,
|
|
15
15
|
} from "@tanstack/react-table"
|
|
16
|
-
import {
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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"
|