@object-ui/plugin-list 3.3.0 → 3.3.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/CHANGELOG.md +12 -0
- package/README.md +21 -1
- package/dist/index.js +16699 -25012
- package/dist/index.umd.cjs +26 -35
- package/dist/packages/plugin-list/src/ListView.d.ts.map +1 -1
- package/dist/plugin-list.css +1 -2
- package/package.json +34 -12
- package/.turbo/turbo-build.log +0 -97
- package/src/ListView.stories.tsx +0 -64
- package/src/ListView.tsx +0 -1725
- package/src/ObjectGallery.tsx +0 -308
- package/src/UserFilters.tsx +0 -453
- package/src/ViewSwitcher.tsx +0 -113
- package/src/__tests__/ConditionalFormatting.test.ts +0 -285
- package/src/__tests__/DataFetch.test.tsx +0 -253
- package/src/__tests__/Export.test.tsx +0 -175
- package/src/__tests__/FilterNormalization.test.ts +0 -162
- package/src/__tests__/GalleryGrouping.test.tsx +0 -237
- package/src/__tests__/GalleryTimelineSpecConfig.test.tsx +0 -203
- package/src/__tests__/ListRefresh.test.tsx +0 -276
- package/src/__tests__/ListView.test.tsx +0 -2153
- package/src/__tests__/ListViewGroupingPropagation.test.tsx +0 -250
- package/src/__tests__/ListViewPersistence.test.tsx +0 -129
- package/src/__tests__/ObjectGallery.test.tsx +0 -208
- package/src/__tests__/TabBar.test.tsx +0 -199
- package/src/__tests__/UserFilters.test.tsx +0 -486
- package/src/components/TabBar.tsx +0 -120
- package/src/index.tsx +0 -78
- package/tsconfig.json +0 -18
- package/vite.config.ts +0 -57
- package/vitest.config.ts +0 -12
- package/vitest.setup.ts +0 -1
package/src/UserFilters.tsx
DELETED
|
@@ -1,453 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import * as React from 'react';
|
|
10
|
-
import { cn, Button, Popover, PopoverContent, PopoverTrigger } from '@object-ui/components';
|
|
11
|
-
import { ChevronDown, X, Plus, SlidersHorizontal } from 'lucide-react';
|
|
12
|
-
import type { ListViewSchema } from '@object-ui/types';
|
|
13
|
-
|
|
14
|
-
/** Resolved option with optional count */
|
|
15
|
-
interface ResolvedOption {
|
|
16
|
-
label: string;
|
|
17
|
-
value: string | number | boolean;
|
|
18
|
-
color?: string;
|
|
19
|
-
count?: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Resolved field with options derived from objectDef when not provided */
|
|
23
|
-
interface ResolvedField {
|
|
24
|
-
field: string;
|
|
25
|
-
label?: string;
|
|
26
|
-
type?: string;
|
|
27
|
-
options: ResolvedOption[];
|
|
28
|
-
showCount?: boolean;
|
|
29
|
-
defaultValues?: (string | number | boolean)[];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface UserFiltersProps {
|
|
33
|
-
config: NonNullable<ListViewSchema['userFilters']>;
|
|
34
|
-
/** Object definition for auto-deriving field options */
|
|
35
|
-
objectDef?: any;
|
|
36
|
-
/** Current data for computing counts */
|
|
37
|
-
data?: any[];
|
|
38
|
-
/** Callback when filter state changes */
|
|
39
|
-
onFilterChange: (filters: any[]) => void;
|
|
40
|
-
/** Maximum visible filter badges before collapsing into "More" dropdown (dropdown mode only) */
|
|
41
|
-
maxVisible?: number;
|
|
42
|
-
className?: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* UserFilters — Airtable Interfaces-style filter bar.
|
|
47
|
-
*
|
|
48
|
-
* Renders one of three modes based on `config.element`:
|
|
49
|
-
* - **dropdown**: field-level dropdown selector badges
|
|
50
|
-
* - **tabs**: named filter preset tab bar
|
|
51
|
-
* - **toggle**: on/off toggle buttons per field
|
|
52
|
-
*/
|
|
53
|
-
export function UserFilters({
|
|
54
|
-
config,
|
|
55
|
-
objectDef,
|
|
56
|
-
data = [],
|
|
57
|
-
onFilterChange,
|
|
58
|
-
maxVisible,
|
|
59
|
-
className,
|
|
60
|
-
}: UserFiltersProps) {
|
|
61
|
-
switch (config.element) {
|
|
62
|
-
case 'dropdown':
|
|
63
|
-
return (
|
|
64
|
-
<DropdownFilters
|
|
65
|
-
fields={config.fields || []}
|
|
66
|
-
objectDef={objectDef}
|
|
67
|
-
data={data}
|
|
68
|
-
onFilterChange={onFilterChange}
|
|
69
|
-
maxVisible={maxVisible}
|
|
70
|
-
className={className}
|
|
71
|
-
/>
|
|
72
|
-
);
|
|
73
|
-
case 'tabs':
|
|
74
|
-
return (
|
|
75
|
-
<TabFilters
|
|
76
|
-
tabs={config.tabs || []}
|
|
77
|
-
showAllRecords={config.showAllRecords !== false}
|
|
78
|
-
allowAddTab={config.allowAddTab}
|
|
79
|
-
onFilterChange={onFilterChange}
|
|
80
|
-
className={className}
|
|
81
|
-
/>
|
|
82
|
-
);
|
|
83
|
-
case 'toggle':
|
|
84
|
-
return (
|
|
85
|
-
<ToggleFilters
|
|
86
|
-
fields={config.fields || []}
|
|
87
|
-
onFilterChange={onFilterChange}
|
|
88
|
-
className={className}
|
|
89
|
-
/>
|
|
90
|
-
);
|
|
91
|
-
default:
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ============================================
|
|
97
|
-
// Shared helper — resolve field options
|
|
98
|
-
// ============================================
|
|
99
|
-
function resolveFields(
|
|
100
|
-
fields: NonNullable<NonNullable<ListViewSchema['userFilters']>['fields']>,
|
|
101
|
-
objectDef: any,
|
|
102
|
-
data: any[],
|
|
103
|
-
): ResolvedField[] {
|
|
104
|
-
return fields.map(f => {
|
|
105
|
-
let options: ResolvedOption[] = f.options ? [...f.options] : [];
|
|
106
|
-
if (options.length === 0 && objectDef?.fields) {
|
|
107
|
-
const fieldDef =
|
|
108
|
-
Array.isArray(objectDef.fields)
|
|
109
|
-
? objectDef.fields.find((fd: any) => fd.name === f.field)
|
|
110
|
-
: objectDef.fields[f.field];
|
|
111
|
-
if (fieldDef?.options) {
|
|
112
|
-
if (Array.isArray(fieldDef.options)) {
|
|
113
|
-
options = fieldDef.options.map((o: any) => ({
|
|
114
|
-
label: o.label ?? String(o.value ?? o),
|
|
115
|
-
value: o.value ?? o,
|
|
116
|
-
color: o.color,
|
|
117
|
-
}));
|
|
118
|
-
} else {
|
|
119
|
-
options = Object.entries(fieldDef.options).map(([value, meta]) => ({
|
|
120
|
-
label: (meta as any)?.label || value,
|
|
121
|
-
value,
|
|
122
|
-
color: (meta as any)?.color,
|
|
123
|
-
}));
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (f.showCount && data.length > 0) {
|
|
128
|
-
options = options.map(opt => ({
|
|
129
|
-
...opt,
|
|
130
|
-
count: data.filter(row => row[f.field] === opt.value).length,
|
|
131
|
-
}));
|
|
132
|
-
}
|
|
133
|
-
return { ...f, options };
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// ============================================
|
|
138
|
-
// Dropdown Mode
|
|
139
|
-
// ============================================
|
|
140
|
-
interface DropdownFiltersProps {
|
|
141
|
-
fields: NonNullable<NonNullable<ListViewSchema['userFilters']>['fields']>;
|
|
142
|
-
objectDef?: any;
|
|
143
|
-
data: any[];
|
|
144
|
-
onFilterChange: (filters: any[]) => void;
|
|
145
|
-
maxVisible?: number;
|
|
146
|
-
className?: string;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, className }: DropdownFiltersProps) {
|
|
150
|
-
const [selectedValues, setSelectedValues] = React.useState<
|
|
151
|
-
Record<string, (string | number | boolean)[]>
|
|
152
|
-
>(() => {
|
|
153
|
-
const init: Record<string, (string | number | boolean)[]> = {};
|
|
154
|
-
fields.forEach(f => {
|
|
155
|
-
if (f.defaultValues && f.defaultValues.length > 0) {
|
|
156
|
-
init[f.field] = f.defaultValues;
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
return init;
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
const resolvedFields = React.useMemo(
|
|
163
|
-
() => resolveFields(fields, objectDef, data),
|
|
164
|
-
[fields, objectDef, data],
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
const emitFilters = React.useCallback(
|
|
168
|
-
(next: Record<string, (string | number | boolean)[]>) => {
|
|
169
|
-
const conditions = Object.entries(next)
|
|
170
|
-
.filter(([, v]) => v.length > 0)
|
|
171
|
-
.map(([field, values]) => [field, 'in', values]);
|
|
172
|
-
onFilterChange(conditions);
|
|
173
|
-
},
|
|
174
|
-
[onFilterChange],
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
const handleChange = (field: string, values: (string | number | boolean)[]) => {
|
|
178
|
-
const next = { ...selectedValues, [field]: values };
|
|
179
|
-
setSelectedValues(next);
|
|
180
|
-
emitFilters(next);
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
// Emit default filters on mount
|
|
184
|
-
React.useEffect(() => {
|
|
185
|
-
const hasDefaults = Object.values(selectedValues).some(v => v.length > 0);
|
|
186
|
-
if (hasDefaults) emitFilters(selectedValues);
|
|
187
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
188
|
-
}, []);
|
|
189
|
-
|
|
190
|
-
// Split fields into visible and overflow based on maxVisible
|
|
191
|
-
const visibleFields = maxVisible !== undefined && maxVisible < resolvedFields.length
|
|
192
|
-
? resolvedFields.slice(0, maxVisible)
|
|
193
|
-
: resolvedFields;
|
|
194
|
-
const overflowFields = maxVisible !== undefined && maxVisible < resolvedFields.length
|
|
195
|
-
? resolvedFields.slice(maxVisible)
|
|
196
|
-
: [];
|
|
197
|
-
|
|
198
|
-
const renderBadge = (f: ResolvedField) => {
|
|
199
|
-
const selected = selectedValues[f.field] || [];
|
|
200
|
-
const hasSelection = selected.length > 0;
|
|
201
|
-
|
|
202
|
-
return (
|
|
203
|
-
<Popover key={f.field}>
|
|
204
|
-
<PopoverTrigger asChild>
|
|
205
|
-
<button
|
|
206
|
-
data-testid={`filter-badge-${f.field}`}
|
|
207
|
-
className={cn(
|
|
208
|
-
'inline-flex items-center gap-1 rounded-full border h-7 px-2.5 text-xs font-medium transition-colors shrink-0',
|
|
209
|
-
hasSelection
|
|
210
|
-
? 'border-primary/30 bg-primary/5 text-primary'
|
|
211
|
-
: 'border-border bg-background hover:bg-accent text-foreground',
|
|
212
|
-
)}
|
|
213
|
-
>
|
|
214
|
-
<span className="truncate max-w-[100px]">{f.label || f.field}</span>
|
|
215
|
-
{hasSelection && (
|
|
216
|
-
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px]">
|
|
217
|
-
{selected.length}
|
|
218
|
-
</span>
|
|
219
|
-
)}
|
|
220
|
-
{hasSelection ? (
|
|
221
|
-
<X
|
|
222
|
-
className="h-3 w-3 opacity-60"
|
|
223
|
-
data-testid={`filter-clear-${f.field}`}
|
|
224
|
-
onClick={e => {
|
|
225
|
-
e.stopPropagation();
|
|
226
|
-
handleChange(f.field, []);
|
|
227
|
-
}}
|
|
228
|
-
/>
|
|
229
|
-
) : (
|
|
230
|
-
<ChevronDown className="h-3 w-3 opacity-60" />
|
|
231
|
-
)}
|
|
232
|
-
</button>
|
|
233
|
-
</PopoverTrigger>
|
|
234
|
-
<PopoverContent align="start" className="w-56 p-2">
|
|
235
|
-
<div className="max-h-60 overflow-y-auto space-y-0.5" data-testid={`filter-options-${f.field}`}>
|
|
236
|
-
{f.options.map(opt => (
|
|
237
|
-
<label
|
|
238
|
-
key={String(opt.value)}
|
|
239
|
-
className={cn(
|
|
240
|
-
'flex items-center gap-2 text-sm py-1.5 px-2 rounded cursor-pointer',
|
|
241
|
-
selected.includes(opt.value) ? 'bg-primary/5 text-primary' : 'hover:bg-muted',
|
|
242
|
-
)}
|
|
243
|
-
>
|
|
244
|
-
<input
|
|
245
|
-
type="checkbox"
|
|
246
|
-
checked={selected.includes(opt.value)}
|
|
247
|
-
onChange={() => {
|
|
248
|
-
const next = selected.includes(opt.value)
|
|
249
|
-
? selected.filter(v => v !== opt.value)
|
|
250
|
-
: [...selected, opt.value];
|
|
251
|
-
handleChange(f.field, next);
|
|
252
|
-
}}
|
|
253
|
-
className="rounded border-input"
|
|
254
|
-
/>
|
|
255
|
-
{opt.color && (
|
|
256
|
-
<span
|
|
257
|
-
className="h-2.5 w-2.5 rounded-full shrink-0"
|
|
258
|
-
style={{ backgroundColor: opt.color }}
|
|
259
|
-
/>
|
|
260
|
-
)}
|
|
261
|
-
<span className="truncate flex-1">{opt.label}</span>
|
|
262
|
-
{opt.count !== undefined && (
|
|
263
|
-
<span className="text-xs text-muted-foreground">{opt.count}</span>
|
|
264
|
-
)}
|
|
265
|
-
</label>
|
|
266
|
-
))}
|
|
267
|
-
</div>
|
|
268
|
-
</PopoverContent>
|
|
269
|
-
</Popover>
|
|
270
|
-
);
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
return (
|
|
274
|
-
<div className={cn('flex items-center gap-1 overflow-x-auto', className)} data-testid="user-filters-dropdown">
|
|
275
|
-
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
276
|
-
{resolvedFields.length === 0 ? (
|
|
277
|
-
<span className="text-xs text-muted-foreground" data-testid="user-filters-empty">
|
|
278
|
-
No filter fields
|
|
279
|
-
</span>
|
|
280
|
-
) : (
|
|
281
|
-
<>
|
|
282
|
-
{visibleFields.map(renderBadge)}
|
|
283
|
-
{overflowFields.length > 0 && (
|
|
284
|
-
<Popover>
|
|
285
|
-
<PopoverTrigger asChild>
|
|
286
|
-
<button
|
|
287
|
-
data-testid="user-filters-more"
|
|
288
|
-
className="inline-flex items-center gap-1 rounded-full border border-border bg-background hover:bg-accent text-foreground h-7 px-2.5 text-xs font-medium transition-colors shrink-0"
|
|
289
|
-
>
|
|
290
|
-
<span>More</span>
|
|
291
|
-
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-muted text-[10px] font-medium">
|
|
292
|
-
{overflowFields.length}
|
|
293
|
-
</span>
|
|
294
|
-
<ChevronDown className="h-3 w-3 opacity-60" />
|
|
295
|
-
</button>
|
|
296
|
-
</PopoverTrigger>
|
|
297
|
-
<PopoverContent align="start" className="w-64 p-2" data-testid="user-filters-more-content">
|
|
298
|
-
<div className="space-y-1">
|
|
299
|
-
{overflowFields.map(renderBadge)}
|
|
300
|
-
</div>
|
|
301
|
-
</PopoverContent>
|
|
302
|
-
</Popover>
|
|
303
|
-
)}
|
|
304
|
-
</>
|
|
305
|
-
)}
|
|
306
|
-
</div>
|
|
307
|
-
);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// ============================================
|
|
311
|
-
// Tabs Mode
|
|
312
|
-
// ============================================
|
|
313
|
-
interface TabFiltersProps {
|
|
314
|
-
tabs: NonNullable<NonNullable<ListViewSchema['userFilters']>['tabs']>;
|
|
315
|
-
showAllRecords?: boolean;
|
|
316
|
-
allowAddTab?: boolean;
|
|
317
|
-
onFilterChange: (filters: any[]) => void;
|
|
318
|
-
className?: string;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function TabFilters({ tabs, showAllRecords, allowAddTab, onFilterChange, className }: TabFiltersProps) {
|
|
322
|
-
const [activeTab, setActiveTab] = React.useState<string>(() => {
|
|
323
|
-
const defaultTab = tabs.find(t => t.default);
|
|
324
|
-
return defaultTab?.id || (showAllRecords ? '__all__' : tabs[0]?.id || '');
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
const handleTabChange = React.useCallback(
|
|
328
|
-
(tabId: string) => {
|
|
329
|
-
setActiveTab(tabId);
|
|
330
|
-
if (tabId === '__all__') {
|
|
331
|
-
onFilterChange([]);
|
|
332
|
-
} else {
|
|
333
|
-
const tab = tabs.find(t => t.id === tabId);
|
|
334
|
-
onFilterChange(tab?.filters || []);
|
|
335
|
-
}
|
|
336
|
-
},
|
|
337
|
-
[tabs, onFilterChange],
|
|
338
|
-
);
|
|
339
|
-
|
|
340
|
-
const allTabs = React.useMemo(() => {
|
|
341
|
-
const result = [...tabs];
|
|
342
|
-
if (showAllRecords) {
|
|
343
|
-
result.push({ id: '__all__', label: 'All records', filters: [] });
|
|
344
|
-
}
|
|
345
|
-
return result;
|
|
346
|
-
}, [tabs, showAllRecords]);
|
|
347
|
-
|
|
348
|
-
// Emit default tab filters on mount
|
|
349
|
-
React.useEffect(() => {
|
|
350
|
-
const defaultTab = tabs.find(t => t.default);
|
|
351
|
-
if (defaultTab) {
|
|
352
|
-
onFilterChange(defaultTab.filters || []);
|
|
353
|
-
}
|
|
354
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
355
|
-
}, []);
|
|
356
|
-
|
|
357
|
-
return (
|
|
358
|
-
<div className={cn('flex items-center gap-0.5 overflow-x-auto', className)} data-testid="user-filters-tabs">
|
|
359
|
-
{allTabs.map(tab => (
|
|
360
|
-
<button
|
|
361
|
-
key={tab.id}
|
|
362
|
-
data-testid={`filter-tab-${tab.id}`}
|
|
363
|
-
onClick={() => handleTabChange(tab.id)}
|
|
364
|
-
className={cn(
|
|
365
|
-
'inline-flex items-center h-7 px-3 text-xs font-medium rounded-md transition-colors shrink-0',
|
|
366
|
-
activeTab === tab.id
|
|
367
|
-
? 'bg-primary text-primary-foreground'
|
|
368
|
-
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
|
|
369
|
-
)}
|
|
370
|
-
>
|
|
371
|
-
{tab.label}
|
|
372
|
-
</button>
|
|
373
|
-
))}
|
|
374
|
-
{allowAddTab && (
|
|
375
|
-
<button
|
|
376
|
-
className="inline-flex items-center justify-center h-7 w-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted shrink-0"
|
|
377
|
-
data-testid="filter-tab-add"
|
|
378
|
-
title="Add filter tab"
|
|
379
|
-
>
|
|
380
|
-
<Plus className="h-3.5 w-3.5" />
|
|
381
|
-
</button>
|
|
382
|
-
)}
|
|
383
|
-
</div>
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// ============================================
|
|
388
|
-
// Toggle Mode
|
|
389
|
-
// ============================================
|
|
390
|
-
interface ToggleFiltersProps {
|
|
391
|
-
fields: NonNullable<NonNullable<ListViewSchema['userFilters']>['fields']>;
|
|
392
|
-
onFilterChange: (filters: any[]) => void;
|
|
393
|
-
className?: string;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function ToggleFilters({ fields, onFilterChange, className }: ToggleFiltersProps) {
|
|
397
|
-
const [activeToggles, setActiveToggles] = React.useState<Set<string>>(() => {
|
|
398
|
-
const defaults = new Set<string>();
|
|
399
|
-
fields.forEach(f => {
|
|
400
|
-
if (f.defaultValues && f.defaultValues.length > 0) defaults.add(f.field);
|
|
401
|
-
});
|
|
402
|
-
return defaults;
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
const emitFilters = React.useCallback(
|
|
406
|
-
(active: Set<string>) => {
|
|
407
|
-
const conditions = Array.from(active).map(fieldName => {
|
|
408
|
-
const fieldDef = fields.find(fd => fd.field === fieldName);
|
|
409
|
-
return fieldDef?.defaultValues
|
|
410
|
-
? [fieldName, 'in', fieldDef.defaultValues]
|
|
411
|
-
: [fieldName, '!=', null];
|
|
412
|
-
});
|
|
413
|
-
onFilterChange(conditions);
|
|
414
|
-
},
|
|
415
|
-
[fields, onFilterChange],
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
const handleToggle = (field: string) => {
|
|
419
|
-
setActiveToggles(prev => {
|
|
420
|
-
const next = new Set(prev);
|
|
421
|
-
if (next.has(field)) next.delete(field);
|
|
422
|
-
else next.add(field);
|
|
423
|
-
emitFilters(next);
|
|
424
|
-
return next;
|
|
425
|
-
});
|
|
426
|
-
};
|
|
427
|
-
|
|
428
|
-
// Emit default filters on mount
|
|
429
|
-
React.useEffect(() => {
|
|
430
|
-
if (activeToggles.size > 0) emitFilters(activeToggles);
|
|
431
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
432
|
-
}, []);
|
|
433
|
-
|
|
434
|
-
return (
|
|
435
|
-
<div className={cn('flex items-center gap-1 overflow-x-auto', className)} data-testid="user-filters-toggle">
|
|
436
|
-
{fields.map(f => {
|
|
437
|
-
const isActive = activeToggles.has(f.field);
|
|
438
|
-
return (
|
|
439
|
-
<Button
|
|
440
|
-
key={f.field}
|
|
441
|
-
variant={isActive ? 'default' : 'outline'}
|
|
442
|
-
size="sm"
|
|
443
|
-
className="h-7 px-3 text-xs shrink-0"
|
|
444
|
-
data-testid={`filter-toggle-${f.field}`}
|
|
445
|
-
onClick={() => handleToggle(f.field)}
|
|
446
|
-
>
|
|
447
|
-
{f.label || f.field}
|
|
448
|
-
</Button>
|
|
449
|
-
);
|
|
450
|
-
})}
|
|
451
|
-
</div>
|
|
452
|
-
);
|
|
453
|
-
}
|
package/src/ViewSwitcher.tsx
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import * as React from 'react';
|
|
10
|
-
import { cn } from '@object-ui/components';
|
|
11
|
-
import {
|
|
12
|
-
Grid,
|
|
13
|
-
LayoutGrid,
|
|
14
|
-
Calendar,
|
|
15
|
-
Images, // gallery
|
|
16
|
-
Activity, // timeline
|
|
17
|
-
GanttChartSquare, // gantt
|
|
18
|
-
Map, // map
|
|
19
|
-
} from 'lucide-react';
|
|
20
|
-
|
|
21
|
-
export type ViewType =
|
|
22
|
-
| 'grid'
|
|
23
|
-
| 'kanban'
|
|
24
|
-
| 'gallery'
|
|
25
|
-
| 'calendar'
|
|
26
|
-
| 'timeline'
|
|
27
|
-
| 'gantt'
|
|
28
|
-
| 'map';
|
|
29
|
-
|
|
30
|
-
export interface ViewSwitcherProps {
|
|
31
|
-
currentView: ViewType;
|
|
32
|
-
availableViews?: ViewType[];
|
|
33
|
-
onViewChange: (view: ViewType) => void;
|
|
34
|
-
className?: string;
|
|
35
|
-
/** Enable animated transitions between views (default: true) */
|
|
36
|
-
animated?: boolean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const VIEW_ICONS: Record<ViewType, React.ReactNode> = {
|
|
40
|
-
grid: <Grid className="h-4 w-4" />,
|
|
41
|
-
kanban: <LayoutGrid className="h-4 w-4" />,
|
|
42
|
-
gallery: <Images className="h-4 w-4" />,
|
|
43
|
-
calendar: <Calendar className="h-4 w-4" />,
|
|
44
|
-
timeline: <Activity className="h-4 w-4" />,
|
|
45
|
-
gantt: <GanttChartSquare className="h-4 w-4" />,
|
|
46
|
-
map: <Map className="h-4 w-4" />,
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const VIEW_LABELS: Record<ViewType, string> = {
|
|
50
|
-
grid: 'Grid',
|
|
51
|
-
kanban: 'Kanban',
|
|
52
|
-
gallery: 'Gallery',
|
|
53
|
-
calendar: 'Calendar',
|
|
54
|
-
timeline: 'Timeline',
|
|
55
|
-
gantt: 'Gantt',
|
|
56
|
-
map: 'Map',
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
export const ViewSwitcher: React.FC<ViewSwitcherProps> = ({
|
|
60
|
-
currentView,
|
|
61
|
-
availableViews = ['grid', 'kanban'],
|
|
62
|
-
onViewChange,
|
|
63
|
-
className,
|
|
64
|
-
animated = true,
|
|
65
|
-
}) => {
|
|
66
|
-
const handleViewChange = React.useCallback(
|
|
67
|
-
(view: ViewType) => {
|
|
68
|
-
if (!animated || view === currentView) {
|
|
69
|
-
onViewChange(view);
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (typeof document !== 'undefined' && 'startViewTransition' in document) {
|
|
74
|
-
(document as Document & {
|
|
75
|
-
startViewTransition: (cb: () => void) => { finished: Promise<void> };
|
|
76
|
-
}).startViewTransition(() => onViewChange(view));
|
|
77
|
-
} else {
|
|
78
|
-
onViewChange(view);
|
|
79
|
-
}
|
|
80
|
-
},
|
|
81
|
-
[animated, currentView, onViewChange],
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
return (
|
|
85
|
-
<div className={cn("flex items-center gap-1 bg-transparent oui-view-switcher", className)}>
|
|
86
|
-
{availableViews.map((view) => {
|
|
87
|
-
const isActive = currentView === view;
|
|
88
|
-
return (
|
|
89
|
-
<button
|
|
90
|
-
key={view}
|
|
91
|
-
type="button"
|
|
92
|
-
onClick={() => handleViewChange(view)}
|
|
93
|
-
aria-label={VIEW_LABELS[view]}
|
|
94
|
-
title={VIEW_LABELS[view]}
|
|
95
|
-
aria-pressed={isActive}
|
|
96
|
-
data-state={isActive ? 'on' : 'off'}
|
|
97
|
-
className={cn(
|
|
98
|
-
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
99
|
-
"hover:bg-muted hover:text-muted-foreground",
|
|
100
|
-
"gap-2 px-3 py-2",
|
|
101
|
-
"data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm border-transparent border data-[state=on]:border-border/50",
|
|
102
|
-
)}
|
|
103
|
-
>
|
|
104
|
-
{VIEW_ICONS[view]}
|
|
105
|
-
<span className="hidden sm:inline-block text-xs font-medium">
|
|
106
|
-
{VIEW_LABELS[view]}
|
|
107
|
-
</span>
|
|
108
|
-
</button>
|
|
109
|
-
);
|
|
110
|
-
})}
|
|
111
|
-
</div>
|
|
112
|
-
);
|
|
113
|
-
};
|