@svadmin/ui 0.92.1 → 0.95.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.
@@ -3,7 +3,7 @@
3
3
  import { Button } from './ui/button/index.js';
4
4
  import { Checkbox } from './ui/checkbox/index.js';
5
5
  import * as Popover from './ui/popover/index.js';
6
- import { SlidersHorizontal, RotateCcw, ArrowUp, ArrowDown, Search } from '@lucide/svelte';
6
+ import { SlidersHorizontal, RotateCcw, ArrowUp, ArrowDown, Search, Lock, Pin, PinOff } from '@lucide/svelte';
7
7
  import { cn } from '../utils.js';
8
8
 
9
9
  export interface ColumnItem {
@@ -11,12 +11,21 @@
11
11
  label: string;
12
12
  visible: boolean;
13
13
  fixed?: 'left' | 'right';
14
+ /** 业务必显列。隐藏、预设和已保存偏好都不能把它关掉。 */
15
+ required?: boolean;
16
+ }
17
+
18
+ export interface ColumnPreset {
19
+ id: string;
20
+ label: string;
21
+ visibleKeys: string[];
14
22
  }
15
23
 
16
24
  interface Props {
17
25
  columns: ColumnItem[];
18
26
  onchange?: (columns: ColumnItem[]) => void;
19
27
  onreset?: () => void;
28
+ presets?: ColumnPreset[];
20
29
  storageKey?: string;
21
30
  title?: string;
22
31
  triggerVariant?: 'outline' | 'ghost' | 'secondary';
@@ -28,6 +37,7 @@
28
37
  columns = $bindable([]),
29
38
  onchange,
30
39
  onreset,
40
+ presets = [],
31
41
  storageKey,
32
42
  title,
33
43
  triggerVariant = 'outline',
@@ -41,6 +51,44 @@
41
51
 
42
52
  // Snapshot initial configuration for reset
43
53
  let initialSnapshot = $state<ColumnItem[]>(JSON.parse(JSON.stringify(columns)));
54
+ let storageHydrated = false;
55
+
56
+ // storageKey 之前只写不读;挂载后按 key 合并可见性、固定位和顺序,未知列仍按新定义保留。
57
+ $effect(() => {
58
+ if (storageHydrated || !storageKey || typeof window === 'undefined') return;
59
+ storageHydrated = true;
60
+ try {
61
+ const raw = window.localStorage.getItem(storageKey);
62
+ if (!raw) return;
63
+ const parsed: unknown = JSON.parse(raw);
64
+ if (!Array.isArray(parsed)) return;
65
+ const stored = new Map(parsed
66
+ .filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
67
+ .filter((entry) => typeof entry['key'] === 'string')
68
+ .map((entry) => [entry['key'] as string, entry]));
69
+ const next = columns.map((column): ColumnItem => {
70
+ const saved = stored.get(column.key);
71
+ if (!saved) return column;
72
+ const savedFixed = saved['fixed'];
73
+ const fixed = savedFixed === 'left' ? 'left' : savedFixed === 'right' ? 'right' : undefined;
74
+ const visible = column.required === true || saved['visible'] !== false;
75
+ if (fixed === undefined) return { ...column, visible };
76
+ return { ...column, visible, fixed };
77
+ });
78
+ const order = parsed
79
+ .map((entry) => (entry && typeof entry === 'object' ? (entry as Record<string, unknown>)['key'] : undefined))
80
+ .filter((key): key is string => typeof key === 'string');
81
+ next.sort((left, right) => {
82
+ const leftIndex = order.indexOf(left.key);
83
+ const rightIndex = order.indexOf(right.key);
84
+ return (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex)
85
+ - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex);
86
+ });
87
+ columns = next;
88
+ } catch {
89
+ // 存储损坏时保持调用方传入的资源默认列。
90
+ }
91
+ });
44
92
 
45
93
  const displayTitle = $derived(title ?? i18n.t('common.columnSettings', { defaultValue: 'Columns' }));
46
94
  const resetLabel = $derived(i18n.t('common.reset', { defaultValue: 'Reset' }));
@@ -65,13 +113,48 @@
65
113
  }
66
114
 
67
115
  function toggleColumn(key: string) {
116
+ const target = columns.find((column) => column.key === key);
117
+ if (!target || target.required) return;
118
+ if (target.visible && columns.filter((column) => column.visible).length <= 1) return;
68
119
  columns = columns.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c));
69
120
  savePreference(columns);
70
121
  onchange?.(columns);
71
122
  }
72
123
 
73
124
  function toggleAll(checked: boolean) {
74
- columns = columns.map((c) => ({ ...c, visible: checked }));
125
+ const hasRequired = columns.some((column) => column.required);
126
+ if (!checked && !hasRequired) {
127
+ // 至少保留一列,避免空表头无法自愈;保留第一列作为最小可见集。
128
+ columns = columns.map((column, index) => ({ ...column, visible: index === 0 }));
129
+ } else {
130
+ columns = columns.map((c) => (c.required ? { ...c, visible: true } : { ...c, visible: checked }));
131
+ }
132
+ savePreference(columns);
133
+ onchange?.(columns);
134
+ }
135
+
136
+ function toggleFixed(key: string) {
137
+ columns = columns.map((column) => {
138
+ if (column.key !== key) return column;
139
+ if (column.fixed) {
140
+ const { fixed: _fixed, ...rest } = column;
141
+ return rest;
142
+ }
143
+ return { ...column, fixed: 'left' as const };
144
+ });
145
+ savePreference(columns);
146
+ onchange?.(columns);
147
+ }
148
+
149
+ function applyPreset(preset: ColumnPreset) {
150
+ const visibleKeys = new Set(preset.visibleKeys);
151
+ columns = columns.map((column) => ({
152
+ ...column,
153
+ visible: column.required === true || visibleKeys.has(column.key),
154
+ }));
155
+ if (!columns.some((column) => column.visible) && columns[0]) {
156
+ columns = columns.map((column, index) => ({ ...column, visible: index === 0 }));
157
+ }
75
158
  savePreference(columns);
76
159
  onchange?.(columns);
77
160
  }
@@ -155,6 +238,20 @@
155
238
  </div>
156
239
  {/if}
157
240
 
241
+ {#if presets.length > 0}
242
+ <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-77a2a20e90d4 svadmin-u-359090c2d529">
243
+ {#each presets as preset (preset.id)}
244
+ <Button
245
+ type="button"
246
+ variant="outline"
247
+ size="sm"
248
+ data-column-preset={preset.id}
249
+ onclick={() => applyPreset(preset)}
250
+ >{preset.label}</Button>
251
+ {/each}
252
+ </div>
253
+ {/if}
254
+
158
255
  <!-- Column list with ordering -->
159
256
  <div class="svadmin-u-50d0d216a2f8 svadmin-u-67d7e383dca6 svadmin-u-92bf82f493b1 svadmin-u-da7c36cd8867 svadmin-u-eda955402ba6">
160
257
  {#each filteredColumns as col (col.key)}
@@ -163,14 +260,32 @@
163
260
  <label class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-77a2a20e90d4 svadmin-u-34516836730d svadmin-u-7f6912283f11 svadmin-u-f283ea9bea0e svadmin-u-36e579c0b41c svadmin-u-aa2c13a5e1b4">
164
261
  <Checkbox
165
262
  checked={col.visible}
263
+ disabled={col.required}
264
+ aria-disabled={col.required ? 'true' : undefined}
265
+ data-column-required={col.required ? col.key : undefined}
166
266
  onCheckedChange={() => toggleColumn(col.key)}
167
267
  />
168
268
  <span class="svadmin-u-f283ea9bea0e svadmin-u-d4108abe6359 {col.visible ? '' : 'svadmin-u-bfa603190748 line-through svadmin-u-f2868c227fcd'}">
169
269
  {col.label}
170
270
  </span>
271
+ {#if col.required}
272
+ <Lock class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29" aria-label="Required column" data-column-required={col.key} />
273
+ {/if}
171
274
  </label>
172
275
 
173
- <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-a3899220f90e svadmin-u-7065497e1ca0 svadmin-u-181f3d6c9821 svadmin-u-67d6184a0024">
276
+ <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-a3899220f90e">
277
+ <Button
278
+ variant="ghost"
279
+ size="icon"
280
+ class="svadmin-u-cd0d9c512cdc svadmin-u-72470489ff4e svadmin-u-bfa603190748 svadmin-u-ea7b2e9e070e"
281
+ aria-label={col.fixed ? 'Cancel fixed column' : 'Fix column left'}
282
+ aria-pressed={Boolean(col.fixed)}
283
+ data-column-fixed={col.fixed ?? 'none'}
284
+ onclick={() => toggleFixed(col.key)}
285
+ >
286
+ {#if col.fixed}<PinOff class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29" />{:else}<Pin class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29" />{/if}
287
+ </Button>
288
+ <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-a3899220f90e svadmin-u-7065497e1ca0 svadmin-u-181f3d6c9821 svadmin-u-67d6184a0024">
174
289
  <Button
175
290
  variant="ghost"
176
291
  size="icon"
@@ -191,6 +306,7 @@
191
306
  >
192
307
  <ArrowDown class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29" />
193
308
  </Button>
309
+ </div>
194
310
  </div>
195
311
  </div>
196
312
  {/each}
@@ -3,11 +3,19 @@ export interface ColumnItem {
3
3
  label: string;
4
4
  visible: boolean;
5
5
  fixed?: 'left' | 'right';
6
+ /** 业务必显列。隐藏、预设和已保存偏好都不能把它关掉。 */
7
+ required?: boolean;
8
+ }
9
+ export interface ColumnPreset {
10
+ id: string;
11
+ label: string;
12
+ visibleKeys: string[];
6
13
  }
7
14
  interface Props {
8
15
  columns: ColumnItem[];
9
16
  onchange?: (columns: ColumnItem[]) => void;
10
17
  onreset?: () => void;
18
+ presets?: ColumnPreset[];
11
19
  storageKey?: string;
12
20
  title?: string;
13
21
  triggerVariant?: 'outline' | 'ghost' | 'secondary';
@@ -23,27 +23,51 @@
23
23
  fields?: FieldDefinition[];
24
24
  filters?: Filter[];
25
25
  logicalOperator?: 'and' | 'or';
26
+ /** 宿主服务端只支持部分操作符时,限制编辑器可选项;默认沿用字段全量操作符。 */
27
+ allowedOperators?: readonly CrudOperator[];
28
+ /** 宿主只支持单一组合逻辑时(如扁平 AND 查询),限制逻辑选择;默认 AND/OR 都可用。 */
29
+ allowedLogicalOperators?: readonly ('and' | 'or')[];
30
+ /** 宿主不支持嵌套条件组时关闭新增入口;已保存的嵌套组仍会按白名单校验。 */
31
+ allowGroups?: boolean;
26
32
  disabled?: boolean;
27
33
  class?: string;
28
34
  onApply?: (filters: Filter[]) => void;
29
35
  onReset?: () => void;
30
36
  onInvalid?: (issues: FilterEditorIssue[]) => void;
31
37
  }
32
- let { fields = [], filters = $bindable([]), logicalOperator = $bindable('and'), disabled = false,
38
+ let { fields = [], filters = $bindable([]), logicalOperator = $bindable('and'), allowedOperators = undefined,
39
+ allowedLogicalOperators = ['and', 'or'], allowGroups = true, disabled = false,
33
40
  class: className, onApply, onReset, onInvalid }: Props = $props();
34
41
  const uid = $props.id();
35
42
  const i18n = useTranslation();
36
43
  const chinese = $derived(i18n.locale.startsWith('zh'));
37
44
  const availableFields = $derived(fields.filter((field) => field.filterable !== false));
45
+ const logicalOperators = $derived<readonly ('and' | 'or')[]>(
46
+ allowedLogicalOperators.length > 0 ? allowedLogicalOperators : ['and'],
47
+ );
48
+ const activeLogicalOperator = $derived<'and' | 'or'>(
49
+ logicalOperators.includes(logicalOperator) ? logicalOperator : (logicalOperators[0] ?? 'and'),
50
+ );
38
51
  // 金额、评分等数字字段沿用 core 的输入语义;树编译器统一按 number 验证。
39
52
  const editorFields = $derived(fields.map(field => isNumericField(field) ? { ...field, type: 'number' as const } : field));
53
+ function operatorsFor(field: FieldDefinition | undefined): CrudOperator[] {
54
+ const operators = filterOperators(field);
55
+ if (!allowedOperators) return operators;
56
+ const allowed = new Set(allowedOperators);
57
+ return operators.filter((operator) => allowed.has(operator));
58
+ }
59
+ function defaultOperator(field: FieldDefinition | undefined): CrudOperator | undefined {
60
+ const operators = operatorsFor(field);
61
+ if (operators.includes('contains')) return 'contains';
62
+ return operators[0];
63
+ }
40
64
  let root = $state<FilterGroupNode>({ kind: 'group', id: 'root', operator: 'and', children: [], wrapped: false });
41
65
  let loadIssues = $state<FilterEditorIssue[]>([]);
42
66
  let attempted = $state(false);
43
67
  let nextId = 0;
44
68
  let lastInputFilters: Filter[] | undefined;
45
69
  let preserved = $state.raw<ReadonlyMap<string, Filter>>(new Map());
46
- const compilation = $derived(compileFilterTree({ ...root, operator: logicalOperator }, editorFields, preserved));
70
+ const compilation = $derived(compileFilterTree({ ...root, operator: activeLogicalOperator }, editorFields, preserved));
47
71
  const issues = $derived(loadIssues.length ? loadIssues : attempted && !compilation.ok ? compilation.issues : []);
48
72
  function countNodes(node: FilterNode): number { return 1 + (node.kind === 'group' ? node.children.reduce((sum, child) => sum + countNodes(child), 0) : 0); }
49
73
  const nodeCount = $derived(countNodes(root));
@@ -54,6 +78,19 @@
54
78
  : { field: node.field, operator: node.operator, value: Array.isArray(node.value) ? [...node.value] : node.value };
55
79
  }
56
80
 
81
+ function unsupportedLogic(node: FilterNode, top = false): FilterIssueCode | undefined {
82
+ if (node.kind === 'rule') return undefined;
83
+ if (!top && !allowGroups) return 'invalid-filter';
84
+ // 顶层扁平 filters 的 AND 是查询语义,不是用户选择的组合逻辑。
85
+ if (!(top && !node.wrapped) && !logicalOperators.includes(node.operator)) return 'logic';
86
+ for (const child of node.children) {
87
+ if (child.kind !== 'group') continue;
88
+ const issue = unsupportedLogic(child);
89
+ if (issue) return issue;
90
+ }
91
+ return undefined;
92
+ }
93
+
57
94
  function cannotEdit(node: FilterRuleNode): boolean {
58
95
  return !compileFilterTree({ kind: 'group', id: 'probe', operator: 'and', children: [node] }, editorFields).ok;
59
96
  }
@@ -71,12 +108,17 @@
71
108
  lastInputFilters = input;
72
109
  const parsed = readFilterTree(input);
73
110
  const saved = new Map<string, Filter>();
74
- if (parsed.ok) {
111
+ const logicIssue = parsed.ok ? unsupportedLogic(parsed.root, true) : undefined;
112
+ if (parsed.ok && !logicIssue) {
75
113
  root = parsed.root;
76
114
  remember(root, saved);
77
115
  loadIssues = [];
78
- if (input.length) logicalOperator = root.operator;
79
- } else {
116
+ if (input.length) logicalOperator = logicalOperators.includes(root.operator)
117
+ ? root.operator : (logicalOperators[0] ?? 'and');
118
+ } else if (parsed.ok && logicIssue) {
119
+ root = { kind: 'group', id: 'root', operator: 'and', children: [], wrapped: false };
120
+ loadIssues = [{ path: 'root', code: logicIssue }];
121
+ } else if (!parsed.ok) {
80
122
  root = { kind: 'group', id: 'root', operator: 'and', children: [], wrapped: false };
81
123
  loadIssues = parsed.issues;
82
124
  }
@@ -99,6 +141,7 @@
99
141
  limit: ['条件数量或深度超过限制', 'Filter count or depth exceeds the limit'],
100
142
  'unknown-field': ['字段不可用于筛选', 'Field is unavailable for filtering'],
101
143
  operator: ['操作符不适用于此字段', 'Operator is not supported for this field'],
144
+ logic: ['组合逻辑不受支持,原条件未被修改', 'Unsupported group logic; the original query is unchanged'],
102
145
  value: ['请输入有效的类型化值', 'Enter a valid typed value'],
103
146
  'empty-group': ['条件组不能为空', 'A filter group cannot be empty'],
104
147
  };
@@ -108,12 +151,14 @@
108
151
  if (disabled || loadIssues.length || full) return;
109
152
  const field = availableFields[0];
110
153
  if (!field) return;
154
+ const operator = defaultOperator(editorFields.find(item => item.key === field.key));
155
+ if (!operator) return;
111
156
  group.children = [...group.children, { kind: 'rule', id: `draft-${++nextId}`, field: field.key,
112
- operator: filterOperators(editorFields.find(item => item.key === field.key)).includes('contains') ? 'contains' : 'eq', value: undefined }];
157
+ operator, value: undefined }];
113
158
  }
114
159
 
115
160
  function appendGroup(group: FilterGroupNode): void {
116
- if (disabled || loadIssues.length || full) return;
161
+ if (!allowGroups || disabled || loadIssues.length || full) return;
117
162
  group.children = [...group.children, { kind: 'group', id: `draft-${++nextId}`, operator: 'and', children: [] }];
118
163
  }
119
164
 
@@ -148,8 +193,10 @@
148
193
 
149
194
  function changeField(rule: FilterRuleNode, key: string): void {
150
195
  if (disabled || rule.readonly) return;
196
+ const operator = defaultOperator(editorFields.find(field => field.key === key));
197
+ if (!operator) return;
151
198
  rule.field = key;
152
- rule.operator = filterOperators(editorFields.find(field => field.key === key)).includes('contains') ? 'contains' : 'eq';
199
+ rule.operator = operator;
153
200
  rule.value = undefined;
154
201
  }
155
202
 
@@ -179,21 +226,25 @@
179
226
  <div class="group-actions">
180
227
  <label for={`${uid}-${group.id}-logic`}>{chinese ? '逻辑' : 'Logic'}</label>
181
228
  <Select id={`${uid}-${group.id}-logic`} aria-label={chinese ? '组合逻辑' : 'Group logic'}
182
- value={top ? logicalOperator : group.operator}
229
+ value={top ? activeLogicalOperator : group.operator}
230
+ disabled={disabled || logicalOperators.length <= 1}
183
231
  onchange={(event: Event) => {
184
232
  const target = event.currentTarget;
185
233
  if (disabled || !(target instanceof HTMLSelectElement)) return;
186
234
  const next = target.value === 'or' ? 'or' : 'and';
235
+ if (!logicalOperators.includes(next)) return;
187
236
  if (top) logicalOperator = next; else group.operator = next;
188
237
  }}>
189
- <option value="and">AND</option><option value="or">OR</option>
238
+ {#each logicalOperators as operator (operator)}<option value={operator}>{operator.toUpperCase()}</option>{/each}
190
239
  </Select>
191
240
  <Button type="button" size="sm" variant="outline" disabled={full || depth >= FILTER_EDITOR_LIMITS.depth || !availableFields.length}
192
241
  data-testid={top ? 'filter-builder-add-rule' : 'filter-group-add-rule'} onclick={() => appendRule(group)}>
193
242
  <Plus size={14} aria-hidden="true" />{i18n.t('common.addRule')}
194
243
  </Button>
195
- <Button type="button" size="sm" variant="ghost" disabled={full || depth >= FILTER_EDITOR_LIMITS.depth - 1}
196
- data-testid="filter-builder-add-group" onclick={() => appendGroup(group)}>{chinese ? '添加条件组' : 'Add group'}</Button>
244
+ {#if allowGroups}
245
+ <Button type="button" size="sm" variant="ghost" disabled={full || depth >= FILTER_EDITOR_LIMITS.depth - 1}
246
+ data-testid="filter-builder-add-group" onclick={() => appendGroup(group)}>{chinese ? '添加条件组' : 'Add group'}</Button>
247
+ {/if}
197
248
  </div>
198
249
  {#each group.children as node (node.id)}
199
250
  {#if node.kind === 'group'}
@@ -203,7 +254,7 @@
203
254
  </div>
204
255
  {:else}
205
256
  {@const field = availableFields.find((item) => item.key === node.field)}
206
- {@const allowed = filterOperators(editorFields.find(item => item.key === node.field))}
257
+ {@const allowed = operatorsFor(editorFields.find(item => item.key === node.field))}
207
258
  {@const collection = isCollectionOperator(node.operator)}
208
259
  {@const invalid = issues.some((issue) => issue.path === node.id)}
209
260
  {@const describedby = issues.flatMap((issue, index) => issue.path === node.id ? [`${uid}-issue-${index}`] : []).join(' ') || undefined}
@@ -260,6 +311,10 @@
260
311
  {:else if !collection && isNumericField(field)}
261
312
  <Input id={`${uid}-${node.id}-value`} type="number" step="any" aria-invalid={invalid} aria-describedby={describedby} value={typeof node.value === 'number' ? node.value : undefined}
262
313
  oninput={(event: Event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
314
+ {:else if !collection && (field?.type === 'date' || field?.type === 'time' || field?.type === 'datetime')}
315
+ <Input id={`${uid}-${node.id}-value`} type={field.type === 'datetime' ? 'datetime-local' : field.type}
316
+ aria-invalid={invalid} aria-describedby={describedby} value={valueText(node.value)}
317
+ oninput={(event: Event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
263
318
  {:else}
264
319
  <Input id={`${uid}-${node.id}-value`} type="text" aria-invalid={invalid} aria-describedby={describedby} value={valueText(node.value)} placeholder={collection ? '[1, 2]' : ''}
265
320
  oninput={(event: Event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
@@ -10,6 +10,12 @@ interface Props {
10
10
  fields?: FieldDefinition[];
11
11
  filters?: Filter[];
12
12
  logicalOperator?: 'and' | 'or';
13
+ /** 宿主服务端只支持部分操作符时,限制编辑器可选项;默认沿用字段全量操作符。 */
14
+ allowedOperators?: readonly CrudOperator[];
15
+ /** 宿主只支持单一组合逻辑时(如扁平 AND 查询),限制逻辑选择;默认 AND/OR 都可用。 */
16
+ allowedLogicalOperators?: readonly ('and' | 'or')[];
17
+ /** 宿主不支持嵌套条件组时关闭新增入口;已保存的嵌套组仍会按白名单校验。 */
18
+ allowGroups?: boolean;
13
19
  disabled?: boolean;
14
20
  class?: string;
15
21
  onApply?: (filters: Filter[]) => void;
@@ -13,8 +13,10 @@
13
13
  icon?: Component<{ class?: string }>;
14
14
  href?: string;
15
15
  onclick?: (e: MouseEvent) => void;
16
- variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
16
+ variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary' | 'primary';
17
17
  disabled?: boolean;
18
+ /** 受控禁用原因;禁用项通过 tooltip/标题与菜单内说明呈现,不改变命令语义。 */
19
+ disabledReason?: string;
18
20
  title?: string;
19
21
  danger?: boolean;
20
22
  hidden?: boolean;
@@ -56,8 +58,14 @@
56
58
  let initialMenuFocus: 'first' | 'last' = 'first';
57
59
  let restoreFocusOnClose = false;
58
60
 
59
- function resolvedVariant(action: RowActionItem): RowActionItem['variant'] {
60
- return action.variant ?? (action.danger ? 'destructive' : 'ghost');
61
+ function resolvedVariant(action: RowActionItem): Exclude<NonNullable<RowActionItem['variant']>, 'primary'> {
62
+ if (action.variant === 'primary') return 'default';
63
+ return (action.variant ?? (action.danger ? 'destructive' : 'ghost')) as Exclude<NonNullable<RowActionItem['variant']>, 'primary'>;
64
+ }
65
+
66
+ function actionTitle(action: RowActionItem): string {
67
+ const base = action.title || action.label;
68
+ return action.disabled && action.disabledReason ? `${base}:${action.disabledReason}` : base;
61
69
  }
62
70
 
63
71
  function handleOverflowAction(action: RowActionItem, event: MouseEvent): void {
@@ -149,7 +157,8 @@
149
157
  size="sm"
150
158
  href={action.href}
151
159
  disabled={action.disabled}
152
- title={action.title || action.label}
160
+ disabledReason={action.disabled ? (action.disabledReason ?? '') : ''}
161
+ title={actionTitle(action)}
153
162
  class="svadmin-u-ed8a5df7b2fb svadmin-u-d5eab218aa34 svadmin-u-359090c2d529"
154
163
  >
155
164
  {#if action.icon}
@@ -164,7 +173,8 @@
164
173
  size="sm"
165
174
  onclick={action.onclick}
166
175
  disabled={action.disabled}
167
- title={action.title || action.label}
176
+ disabledReason={action.disabled ? (action.disabledReason ?? '') : ''}
177
+ title={actionTitle(action)}
168
178
  class="svadmin-u-ed8a5df7b2fb svadmin-u-d5eab218aa34 svadmin-u-359090c2d529"
169
179
  >
170
180
  {#if action.icon}
@@ -193,9 +203,11 @@
193
203
  {...definedOptions({ "variant": resolvedVariant(action) })}
194
204
  size="sm"
195
205
  disabled={action.disabled}
206
+ disabledReason={action.disabled ? (action.disabledReason ?? '') : ''}
196
207
  onclick={(event) => handleOverflowAction(action, event)}
197
208
  role="menuitem"
198
209
  tabindex={-1}
210
+ title={actionTitle(action)}
199
211
  class="svadmin-u-b8f0a08ece1e svadmin-u-6da6a3c3f741 svadmin-u-4b5cc19bfdfc svadmin-u-421ac2be5045 svadmin-u-d5eab218aa34 svadmin-u-ec0091ee009b svadmin-u-fc7473ca09eb svadmin-u-8ecebc9f80e6"
200
212
  >
201
213
  {#if action.icon}
@@ -211,12 +223,18 @@
211
223
  destructive={action.danger || action.variant === 'destructive'}
212
224
  role="menuitem"
213
225
  tabindex={-1}
226
+ title={actionTitle(action)}
214
227
  >
215
228
  {#if action.icon}
216
229
  {@const Icon = action.icon}
217
230
  <Icon class="svadmin-u-783b0d9d1e2c" />
218
231
  {/if}
219
- {action.label}
232
+ <span class="row-action-label">
233
+ <span>{action.label}</span>
234
+ {#if action.disabled && action.disabledReason}
235
+ <span class="row-action-reason">{action.disabledReason}</span>
236
+ {/if}
237
+ </span>
220
238
  </DropdownMenu.Item>
221
239
  {/if}
222
240
  {/each}
@@ -227,3 +245,9 @@
227
245
  </DropdownMenu.Root>
228
246
  {/if}
229
247
  </div>
248
+
249
+ <style>
250
+ .row-action-label { display: inline-flex; flex-direction: column; align-items: flex-start; gap: .125rem; min-width: 0; }
251
+ .row-action-label > span:first-child { overflow: hidden; text-overflow: ellipsis; }
252
+ .row-action-reason { font-size: .75em; line-height: 1.3; opacity: .72; white-space: normal; }
253
+ </style>
@@ -8,8 +8,10 @@ export type RowActionItem = {
8
8
  }>;
9
9
  href?: string;
10
10
  onclick?: (e: MouseEvent) => void;
11
- variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
11
+ variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary' | 'primary';
12
12
  disabled?: boolean;
13
+ /** 受控禁用原因;禁用项通过 tooltip/标题与菜单内说明呈现,不改变命令语义。 */
14
+ disabledReason?: string;
13
15
  title?: string;
14
16
  danger?: boolean;
15
17
  hidden?: boolean;
@@ -20,7 +20,7 @@ export interface FilterGroupNode {
20
20
  wrapped?: boolean;
21
21
  }
22
22
  export type FilterNode = FilterRuleNode | FilterGroupNode;
23
- export type FilterIssueCode = 'invalid-filter' | 'limit' | 'unknown-field' | 'operator' | 'value' | 'empty-group';
23
+ export type FilterIssueCode = 'invalid-filter' | 'limit' | 'unknown-field' | 'operator' | 'logic' | 'value' | 'empty-group';
24
24
  export interface FilterEditorIssue {
25
25
  path: string;
26
26
  code: FilterIssueCode;
package/dist/index.d.ts CHANGED
@@ -92,7 +92,7 @@ export { default as FilterBuilder } from './components/FilterBuilder.svelte';
92
92
  export type { FilterRuleItem } from './components/FilterBuilder.svelte';
93
93
  export { default as Watermark } from './components/Watermark.svelte';
94
94
  export { default as ColumnSettings } from './components/ColumnSettings.svelte';
95
- export type { ColumnItem } from './components/ColumnSettings.svelte';
95
+ export type { ColumnItem, ColumnPreset } from './components/ColumnSettings.svelte';
96
96
  export { default as UnsavedChangesPrompt } from './components/UnsavedChangesPrompt.svelte';
97
97
  export { default as ImportWizard } from './components/ImportWizard.svelte';
98
98
  export type { ImportPreviewFailure, ImportPreviewResult } from '@svadmin/core';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.92.1",
3
+ "version": "0.95.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -88,7 +88,7 @@
88
88
  "@codemirror/lang-json": "^6.0.0",
89
89
  "@codemirror/lang-markdown": "^6.0.0",
90
90
  "@codemirror/lang-sql": "^6.0.0",
91
- "@svadmin/core": "^0.66.1",
91
+ "@svadmin/core": "^0.66.2",
92
92
  "@tanstack/svelte-query": "^6.1.48",
93
93
  "codemirror": "^6.0.0",
94
94
  "isomorphic-dompurify": "^3.23.0",
@@ -139,7 +139,7 @@
139
139
  }
140
140
  },
141
141
  "dependencies": {
142
- "@svadmin/devtools": "^0.3.8",
142
+ "@svadmin/devtools": "^0.3.9",
143
143
  "@svadmin/devtools-contract": "^0.3.1",
144
144
  "@vibeunion/devtools-devframe": "^0.1.0",
145
145
  "typebox": "^1.3.34",
@@ -177,7 +177,7 @@
177
177
  "@codemirror/lang-markdown": "^6.5.2",
178
178
  "@codemirror/lang-sql": "^6.10.0",
179
179
  "@happy-dom/global-registrator": "^20.11.12",
180
- "@svadmin/ai-elements": "^0.12.5",
180
+ "@svadmin/ai-elements": "^0.12.6",
181
181
  "@sveltejs/vite-plugin-svelte": "^7.3.0",
182
182
  "@tailwindcss/cli": "4.3.3",
183
183
  "@testing-library/dom": "^10.4.1",