@agions/taroviz 1.6.0 → 1.7.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,587 @@
1
+ /**
2
+ * DataFilter - 数据筛选器 UI 组件
3
+ * 提供独立的数据筛选交互界面,支持多种筛选类型
4
+ */
5
+ import React, { useState, useCallback, useMemo } from 'react';
6
+
7
+ // ============================================================================
8
+ // 类型定义
9
+ // ============================================================================
10
+
11
+ /** 筛选字段类型 */
12
+ export type FilterFieldType = 'select' | 'range' | 'checkbox' | 'date';
13
+
14
+ /** 单个筛选字段配置 */
15
+ export interface FilterField {
16
+ /** 字段唯一标识 */
17
+ key: string;
18
+ /** 字段显示名称 */
19
+ label: string;
20
+ /** 字段类型 */
21
+ type: FilterFieldType;
22
+ /** 下拉选项(select/checkbox 类型使用) */
23
+ options?: Array<{ label: string; value: string | number }>;
24
+ /** 范围最小值(range 类型使用) */
25
+ min?: number;
26
+ /** 范围最大值(range 类型使用) */
27
+ max?: number;
28
+ /** 日期格式(date 类型使用) */
29
+ dateFormat?: string;
30
+ /** placeholder */
31
+ placeholder?: string;
32
+ }
33
+
34
+ /** 筛选值类型 */
35
+ export type FilterValue = string | number | boolean | [number, number] | [string, string] | string[] | number[] | undefined;
36
+
37
+ /** 筛选器完整值 */
38
+ export interface FilterValues {
39
+ [key: string]: FilterValue;
40
+ }
41
+
42
+ /** DataFilter 组件属性 */
43
+ export interface DataFilterProps {
44
+ /** 筛选字段配置 */
45
+ fields: FilterField[];
46
+ /** 当前筛选值 */
47
+ value?: FilterValues;
48
+ /** 筛选变化回调 */
49
+ onChange?: (filters: FilterValues) => void;
50
+ /** 布局方向 */
51
+ layout?: 'horizontal' | 'vertical';
52
+ /** 是否显示重置按钮 */
53
+ showReset?: boolean;
54
+ /** 自定义样式 */
55
+ className?: string;
56
+ /** 自定义样式对象 */
57
+ style?: React.CSSProperties;
58
+ /** 紧凑模式 */
59
+ compact?: boolean;
60
+ /** 禁用状态 */
61
+ disabled?: boolean;
62
+ /** 重置按钮文本 */
63
+ resetText?: string;
64
+ /** 提交按钮文本 */
65
+ submitText?: string;
66
+ /** 是否显示提交按钮(实时模式下隐藏) */
67
+ showSubmit?: boolean;
68
+ /** 是否实时触发 onChange(启用时每次筛选变化立即触发) */
69
+ liveUpdate?: boolean;
70
+ }
71
+
72
+ // ============================================================================
73
+ // 样式常量
74
+ // ============================================================================
75
+
76
+ const BASE_STYLE: React.CSSProperties = {
77
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
78
+ fontSize: '14px',
79
+ color: '#333',
80
+ };
81
+
82
+ const FIELD_STYLE: React.CSSProperties = {
83
+ display: 'flex',
84
+ flexDirection: 'column',
85
+ gap: '4px',
86
+ };
87
+
88
+ const LABEL_STYLE: React.CSSProperties = {
89
+ fontSize: '12px',
90
+ fontWeight: 500,
91
+ color: '#666',
92
+ };
93
+
94
+ const INPUT_BASE: React.CSSProperties = {
95
+ padding: '6px 10px',
96
+ border: '1px solid #d9d9d9',
97
+ borderRadius: '4px',
98
+ fontSize: '13px',
99
+ outline: 'none',
100
+ transition: 'border-color 0.2s, box-shadow 0.2s',
101
+ backgroundColor: '#fff',
102
+ };
103
+
104
+ const BUTTON_BASE: React.CSSProperties = {
105
+ padding: '6px 16px',
106
+ borderRadius: '4px',
107
+ fontSize: '13px',
108
+ cursor: 'pointer',
109
+ border: 'none',
110
+ transition: 'all 0.2s',
111
+ };
112
+
113
+ // ============================================================================
114
+ // SelectFilter 组件
115
+ // ============================================================================
116
+
117
+ interface SelectFilterProps {
118
+ field: FilterField;
119
+ value: FilterValue;
120
+ onChange: (key: string, value: FilterValue) => void;
121
+ disabled?: boolean;
122
+ compact?: boolean;
123
+ }
124
+
125
+ const SelectFilter: React.FC<SelectFilterProps> = ({ field, value, onChange, disabled, compact }) => {
126
+ const handleChange = useCallback(
127
+ (e: React.ChangeEvent<HTMLSelectElement>) => {
128
+ const val = e.target.value;
129
+ onChange(field.key, val === '' ? undefined : val);
130
+ },
131
+ [field.key, onChange]
132
+ );
133
+
134
+ return (
135
+ <div style={FIELD_STYLE}>
136
+ <label style={LABEL_STYLE}>{field.label}</label>
137
+ <select
138
+ value={(value as string) ?? ''}
139
+ onChange={handleChange}
140
+ disabled={disabled}
141
+ style={{
142
+ ...INPUT_BASE,
143
+ minWidth: compact ? '100px' : '140px',
144
+ width: '100%',
145
+ cursor: disabled ? 'not-allowed' : 'pointer',
146
+ opacity: disabled ? 0.6 : 1,
147
+ }}
148
+ >
149
+ <option value="">{field.placeholder || '请选择'}</option>
150
+ {field.options?.map((opt) => (
151
+ <option key={String(opt.value)} value={String(opt.value)}>
152
+ {opt.label}
153
+ </option>
154
+ ))}
155
+ </select>
156
+ </div>
157
+ );
158
+ };
159
+
160
+ // ============================================================================
161
+ // RangeFilter 组件
162
+ // ============================================================================
163
+
164
+ interface RangeFilterProps {
165
+ field: FilterField;
166
+ value: FilterValue;
167
+ onChange: (key: string, value: FilterValue) => void;
168
+ onLiveChange?: boolean;
169
+ disabled?: boolean;
170
+ compact?: boolean;
171
+ }
172
+
173
+ const RangeFilter: React.FC<RangeFilterProps> = ({ field, value, onChange, disabled, compact }) => {
174
+ const rangeValue = (value as [number, number]) ?? [field.min ?? 0, field.max ?? 100];
175
+
176
+ const handleMinChange = useCallback(
177
+ (e: React.ChangeEvent<HTMLInputElement>) => {
178
+ const newMin = Number(e.target.value);
179
+ onChange(field.key, [newMin, rangeValue[1]]);
180
+ },
181
+ [field.key, rangeValue, onChange]
182
+ );
183
+
184
+ const handleMaxChange = useCallback(
185
+ (e: React.ChangeEvent<HTMLInputElement>) => {
186
+ const newMax = Number(e.target.value);
187
+ onChange(field.key, [rangeValue[0], newMax]);
188
+ },
189
+ [field.key, rangeValue, onChange]
190
+ );
191
+
192
+ return (
193
+ <div style={FIELD_STYLE}>
194
+ <label style={LABEL_STYLE}>{field.label}</label>
195
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
196
+ <input
197
+ type="number"
198
+ value={rangeValue[0]}
199
+ min={field.min}
200
+ max={rangeValue[1]}
201
+ onChange={handleMinChange}
202
+ disabled={disabled}
203
+ placeholder={String(field.min ?? '最小')}
204
+ style={{
205
+ ...INPUT_BASE,
206
+ width: compact ? '60px' : '80px',
207
+ cursor: disabled ? 'not-allowed' : 'text',
208
+ opacity: disabled ? 0.6 : 1,
209
+ }}
210
+ />
211
+ <span style={{ color: '#999', fontSize: '12px' }}>~</span>
212
+ <input
213
+ type="number"
214
+ value={rangeValue[1]}
215
+ min={rangeValue[0]}
216
+ max={field.max}
217
+ onChange={handleMaxChange}
218
+ disabled={disabled}
219
+ placeholder={String(field.max ?? '最大')}
220
+ style={{
221
+ ...INPUT_BASE,
222
+ width: compact ? '60px' : '80px',
223
+ cursor: disabled ? 'not-allowed' : 'text',
224
+ opacity: disabled ? 0.6 : 1,
225
+ }}
226
+ />
227
+ </div>
228
+ </div>
229
+ );
230
+ };
231
+
232
+ // ============================================================================
233
+ // CheckboxFilter 组件(多选)
234
+ // ============================================================================
235
+
236
+ interface CheckboxFilterProps {
237
+ field: FilterField;
238
+ value: FilterValue;
239
+ onChange: (key: string, value: FilterValue) => void;
240
+ disabled?: boolean;
241
+ compact?: boolean;
242
+ }
243
+
244
+ const CheckboxFilter: React.FC<CheckboxFilterProps> = ({ field, value, onChange, disabled, compact }) => {
245
+ const selectedValues = useMemo(() => {
246
+ if (Array.isArray(value)) {
247
+ return new Set(value.map(String));
248
+ }
249
+ return new Set<string>();
250
+ }, [value]);
251
+
252
+ const handleToggle = useCallback(
253
+ (optValue: string | number) => {
254
+ const strVal = String(optValue);
255
+ const newSet = new Set(selectedValues);
256
+ if (newSet.has(strVal)) {
257
+ newSet.delete(strVal);
258
+ } else {
259
+ newSet.add(strVal);
260
+ }
261
+ onChange(field.key, Array.from(newSet));
262
+ },
263
+ [field.key, selectedValues, onChange]
264
+ );
265
+
266
+ return (
267
+ <div style={FIELD_STYLE}>
268
+ <label style={LABEL_STYLE}>{field.label}</label>
269
+ <div
270
+ style={{
271
+ display: 'flex',
272
+ flexWrap: 'wrap',
273
+ gap: compact ? '4px' : '8px',
274
+ maxWidth: compact ? '200px' : '300px',
275
+ }}
276
+ >
277
+ {field.options?.map((opt) => {
278
+ const isSelected = selectedValues.has(String(opt.value));
279
+ return (
280
+ <label
281
+ key={String(opt.value)}
282
+ style={{
283
+ display: 'flex',
284
+ alignItems: 'center',
285
+ gap: '4px',
286
+ cursor: disabled ? 'not-allowed' : 'pointer',
287
+ fontSize: '13px',
288
+ opacity: disabled ? 0.6 : 1,
289
+ userSelect: 'none',
290
+ }}
291
+ >
292
+ <input
293
+ type="checkbox"
294
+ checked={isSelected}
295
+ onChange={() => handleToggle(opt.value)}
296
+ disabled={disabled}
297
+ style={{ cursor: disabled ? 'not-allowed' : 'pointer' }}
298
+ />
299
+ <span>{opt.label}</span>
300
+ </label>
301
+ );
302
+ })}
303
+ </div>
304
+ </div>
305
+ );
306
+ };
307
+
308
+ // ============================================================================
309
+ // DateFilter 组件
310
+ // ============================================================================
311
+
312
+ interface DateFilterProps {
313
+ field: FilterField;
314
+ value: FilterValue;
315
+ onChange: (key: string, value: FilterValue) => void;
316
+ disabled?: boolean;
317
+ compact?: boolean;
318
+ }
319
+
320
+ const DateFilter: React.FC<DateFilterProps> = ({ field, value, onChange, disabled, compact }) => {
321
+ const dateValue = (value as [string, string]) ?? ['', ''];
322
+ const dateFormat = field.dateFormat || 'YYYY-MM-DD';
323
+
324
+ const handleStartChange = useCallback(
325
+ (e: React.ChangeEvent<HTMLInputElement>) => {
326
+ onChange(field.key, [e.target.value, dateValue[1]]);
327
+ },
328
+ [field.key, dateValue, onChange]
329
+ );
330
+
331
+ const handleEndChange = useCallback(
332
+ (e: React.ChangeEvent<HTMLInputElement>) => {
333
+ onChange(field.key, [dateValue[0], e.target.value]);
334
+ },
335
+ [field.key, dateValue, onChange]
336
+ );
337
+
338
+ return (
339
+ <div style={FIELD_STYLE}>
340
+ <label style={LABEL_STYLE}>{field.label}</label>
341
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
342
+ <input
343
+ type="date"
344
+ value={dateValue[0]}
345
+ onChange={handleStartChange}
346
+ disabled={disabled}
347
+ style={{
348
+ ...INPUT_BASE,
349
+ width: compact ? '120px' : '150px',
350
+ cursor: disabled ? 'not-allowed' : 'text',
351
+ opacity: disabled ? 0.6 : 1,
352
+ }}
353
+ />
354
+ <span style={{ color: '#999', fontSize: '12px' }}>~</span>
355
+ <input
356
+ type="date"
357
+ value={dateValue[1]}
358
+ onChange={handleEndChange}
359
+ disabled={disabled}
360
+ style={{
361
+ ...INPUT_BASE,
362
+ width: compact ? '120px' : '150px',
363
+ cursor: disabled ? 'not-allowed' : 'text',
364
+ opacity: disabled ? 0.6 : 1,
365
+ }}
366
+ />
367
+ </div>
368
+ </div>
369
+ );
370
+ };
371
+
372
+ // ============================================================================
373
+ // DataFilter 主组件
374
+ // ============================================================================
375
+
376
+ /**
377
+ * DataFilter - 数据筛选器 UI 组件
378
+ *
379
+ * @example
380
+ * ```tsx
381
+ * <DataFilter
382
+ * fields={[
383
+ * { key: 'category', label: '分类', type: 'select', options: [{ label: '苹果', value: 'apple' }] },
384
+ * { key: 'price', label: '价格区间', type: 'range', min: 0, max: 1000 },
385
+ * { key: 'tags', label: '标签', type: 'checkbox', options: [{ label: '热门', value: 'hot' }] },
386
+ * { key: 'date', label: '日期', type: 'date' },
387
+ * ]}
388
+ * value={filters}
389
+ * onChange={(filters) => setFilters(filters)}
390
+ * layout="horizontal"
391
+ * showReset
392
+ * />
393
+ * ```
394
+ */
395
+ export const DataFilter: React.FC<DataFilterProps> = ({
396
+ fields,
397
+ value = {},
398
+ onChange,
399
+ layout = 'vertical',
400
+ showReset = false,
401
+ className = '',
402
+ style,
403
+ compact = false,
404
+ disabled = false,
405
+ resetText = '重置',
406
+ submitText = '筛选',
407
+ showSubmit = false,
408
+ liveUpdate = true,
409
+ }) => {
410
+ // 内部状态,用于管理表单值
411
+ const [internalValue, setInternalValue] = useState<FilterValues>(value);
412
+
413
+ // 当外部 value 变化时同步内部状态
414
+ React.useEffect(() => {
415
+ setInternalValue(value);
416
+ }, [value]);
417
+
418
+ // 是否有筛选条件
419
+ const hasFilters = useMemo(() => {
420
+ return Object.values(internalValue).some((v) => {
421
+ if (v === undefined || v === null || v === '') return false;
422
+ if (Array.isArray(v) && v.length === 0) return false;
423
+ return true;
424
+ });
425
+ }, [internalValue]);
426
+
427
+ // 处理单个字段变化
428
+ const handleFieldChange = useCallback(
429
+ (key: string, fieldValue: FilterValue) => {
430
+ const newValue = { ...internalValue, [key]: fieldValue };
431
+ setInternalValue(newValue);
432
+ if (liveUpdate) {
433
+ onChange?.(newValue);
434
+ }
435
+ },
436
+ [internalValue, liveUpdate, onChange]
437
+ );
438
+
439
+ // 提交筛选
440
+ const handleSubmit = useCallback(() => {
441
+ onChange?.(internalValue);
442
+ }, [internalValue, onChange]);
443
+
444
+ // 重置筛选
445
+ const handleReset = useCallback(() => {
446
+ const emptyValues: FilterValues = {};
447
+ fields.forEach((f) => {
448
+ if (f.type === 'range') {
449
+ emptyValues[f.key] = [f.min ?? 0, f.max ?? 100];
450
+ } else if (f.type === 'checkbox') {
451
+ emptyValues[f.key] = [];
452
+ } else if (f.type === 'date') {
453
+ emptyValues[f.key] = ['', ''];
454
+ } else {
455
+ emptyValues[f.key] = undefined;
456
+ }
457
+ });
458
+ setInternalValue(emptyValues);
459
+ onChange?.(emptyValues);
460
+ }, [fields, onChange]);
461
+
462
+ // 渲染单个字段
463
+ const renderField = useCallback(
464
+ (field: FilterField) => {
465
+ const fieldValue = internalValue[field.key];
466
+
467
+ switch (field.type) {
468
+ case 'select':
469
+ return (
470
+ <SelectFilter
471
+ key={field.key}
472
+ field={field}
473
+ value={fieldValue}
474
+ onChange={handleFieldChange}
475
+ disabled={disabled}
476
+ compact={compact}
477
+ />
478
+ );
479
+ case 'range':
480
+ return (
481
+ <RangeFilter
482
+ key={field.key}
483
+ field={field}
484
+ value={fieldValue}
485
+ onChange={handleFieldChange}
486
+ disabled={disabled}
487
+ compact={compact}
488
+ />
489
+ );
490
+ case 'checkbox':
491
+ return (
492
+ <CheckboxFilter
493
+ key={field.key}
494
+ field={field}
495
+ value={fieldValue}
496
+ onChange={handleFieldChange}
497
+ disabled={disabled}
498
+ compact={compact}
499
+ />
500
+ );
501
+ case 'date':
502
+ return (
503
+ <DateFilter
504
+ key={field.key}
505
+ field={field}
506
+ value={fieldValue}
507
+ onChange={handleFieldChange}
508
+ disabled={disabled}
509
+ compact={compact}
510
+ />
511
+ );
512
+ default:
513
+ return null;
514
+ }
515
+ },
516
+ [internalValue, handleFieldChange, disabled, compact]
517
+ );
518
+
519
+ const isHorizontal = layout === 'horizontal';
520
+
521
+ const containerStyle: React.CSSProperties = {
522
+ ...BASE_STYLE,
523
+ display: 'flex',
524
+ flexDirection: isHorizontal ? 'row' : 'column',
525
+ flexWrap: 'wrap',
526
+ gap: isHorizontal ? '16px' : '12px',
527
+ alignItems: isHorizontal ? 'flex-end' : 'flex-start',
528
+ padding: '12px',
529
+ backgroundColor: '#fafafa',
530
+ borderRadius: '6px',
531
+ border: '1px solid #f0f0f0',
532
+ ...style,
533
+ };
534
+
535
+ return (
536
+ <div className={`taroviz-datafilter ${className}`} style={containerStyle}>
537
+ {fields.map(renderField)}
538
+
539
+ <div
540
+ style={{
541
+ display: 'flex',
542
+ gap: '8px',
543
+ alignItems: 'flex-end',
544
+ marginLeft: isHorizontal ? '8px' : '0',
545
+ paddingTop: isHorizontal ? '0' : '4px',
546
+ }}
547
+ >
548
+ {showSubmit && !liveUpdate && (
549
+ <button
550
+ type="button"
551
+ onClick={handleSubmit}
552
+ disabled={disabled}
553
+ style={{
554
+ ...BUTTON_BASE,
555
+ backgroundColor: '#1890ff',
556
+ color: '#fff',
557
+ cursor: disabled ? 'not-allowed' : 'pointer',
558
+ opacity: disabled ? 0.6 : 1,
559
+ }}
560
+ >
561
+ {submitText}
562
+ </button>
563
+ )}
564
+
565
+ {showReset && hasFilters && (
566
+ <button
567
+ type="button"
568
+ onClick={handleReset}
569
+ disabled={disabled}
570
+ style={{
571
+ ...BUTTON_BASE,
572
+ backgroundColor: '#fff',
573
+ color: '#666',
574
+ border: '1px solid #d9d9d9',
575
+ cursor: disabled ? 'not-allowed' : 'pointer',
576
+ opacity: disabled ? 0.6 : 1,
577
+ }}
578
+ >
579
+ {resetText}
580
+ </button>
581
+ )}
582
+ </div>
583
+ </div>
584
+ );
585
+ };
586
+
587
+ export default DataFilter;