@svadmin/lite 0.12.0 → 0.14.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/README.md CHANGED
@@ -13,7 +13,7 @@ The main `@svadmin/ui` package delivers a premium SPA experience using Svelte 5,
13
13
  **What Lite is:**
14
14
  - A **Zero-JS SSR-first admin component suite** designed with an IE11-safe CSS baseline.
15
15
  - 100% interoperable with `@svadmin/core` — sharing the same `DataProvider`, `AuthProvider`, `Resource`, and `FieldDefinition` contracts.
16
- - An architectural companion to `@svadmin/ui` achieving **100% component parity (103/103 components)** through 1:1 server-rendered matches, deliberate semantic fallbacks, or explicit SPA-only classification.
16
+ - An architectural companion to `@svadmin/ui` with tracked server-rendered mappings, semantic fallbacks, and explicit SPA-only classifications in `PARITY.md`. Coverage measures the listed mappings, not a complete public-export inventory or verified behavioral equivalence.
17
17
  - **Not** a generic Tailwind-to-IE11 compiler or a full client-side port of shadcn-svelte (which depends on modern browser JS runtimes and CSS custom properties).
18
18
 
19
19
  `@svadmin/lite` provides a server-rendered fallback that **shares the same DataProvider, AuthProvider, and Resource definitions** — only the rendering layer is different.
@@ -0,0 +1,34 @@
1
+ <script lang="ts">
2
+ export interface LiteFormErrorEntry {
3
+ fieldKey: string;
4
+ label: string;
5
+ message: string;
6
+ /** 可选的表单控件 id;提供后错误项会链接到该控件。 */
7
+ fieldId?: string;
8
+ }
9
+
10
+ let {
11
+ errors = [],
12
+ title = 'Please fix the following errors',
13
+ }: {
14
+ errors?: LiteFormErrorEntry[];
15
+ title?: string;
16
+ } = $props();
17
+ </script>
18
+
19
+ {#if errors.length > 0}
20
+ <section class="lite-error-summary" aria-labelledby="lite-error-summary-title">
21
+ <h2 id="lite-error-summary-title">{title}</h2>
22
+ <ul>
23
+ {#each errors as error (error.fieldKey)}
24
+ <li>
25
+ {#if error.fieldId}
26
+ <a href={`#${error.fieldId}`}>{error.label}: {error.message}</a>
27
+ {:else}
28
+ <span>{error.label}: {error.message}</span>
29
+ {/if}
30
+ </li>
31
+ {/each}
32
+ </ul>
33
+ </section>
34
+ {/if}
@@ -0,0 +1,14 @@
1
+ export interface LiteFormErrorEntry {
2
+ fieldKey: string;
3
+ label: string;
4
+ message: string;
5
+ /** 可选的表单控件 id;提供后错误项会链接到该控件。 */
6
+ fieldId?: string;
7
+ }
8
+ type $$ComponentProps = {
9
+ errors?: LiteFormErrorEntry[];
10
+ title?: string;
11
+ };
12
+ declare const LiteErrorSummary: import("svelte").Component<$$ComponentProps, {}, "">;
13
+ type LiteErrorSummary = ReturnType<typeof LiteErrorSummary>;
14
+ export default LiteErrorSummary;
@@ -0,0 +1,53 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ name: string;
4
+ label: string;
5
+ id?: string;
6
+ form?: string;
7
+ accept?: string;
8
+ multiple?: boolean;
9
+ required?: boolean;
10
+ disabled?: boolean;
11
+ /** Explicit undefined clears a previously displayed server error. */
12
+ error?: string | undefined;
13
+ class?: string;
14
+ }
15
+
16
+ let {
17
+ name,
18
+ label,
19
+ id,
20
+ form,
21
+ accept,
22
+ multiple = false,
23
+ required = false,
24
+ disabled = false,
25
+ error,
26
+ class: className = '',
27
+ }: Props = $props();
28
+
29
+ const generatedId = $props.id();
30
+ const controlId = $derived(id ?? generatedId);
31
+ </script>
32
+
33
+ <div class={`lite-form-group ${className}`}>
34
+ <label for={controlId}>{label}</label>
35
+ <!-- 保留可见原生控件,无客户端脚本时仍可显示浏览器选择状态。 -->
36
+ <input
37
+ type="file"
38
+ id={controlId}
39
+ {name}
40
+ {form}
41
+ {accept}
42
+ {multiple}
43
+ {required}
44
+ {disabled}
45
+ class:lite-input-error={!!error}
46
+ class="lite-input"
47
+ aria-invalid={error ? 'true' : undefined}
48
+ aria-describedby={error ? `${controlId}-error` : undefined}
49
+ />
50
+ {#if error}
51
+ <p id={`${controlId}-error`} class="lite-error-text" role="alert">{error}</p>
52
+ {/if}
53
+ </div>
@@ -0,0 +1,16 @@
1
+ interface Props {
2
+ name: string;
3
+ label: string;
4
+ id?: string;
5
+ form?: string;
6
+ accept?: string;
7
+ multiple?: boolean;
8
+ required?: boolean;
9
+ disabled?: boolean;
10
+ /** Explicit undefined clears a previously displayed server error. */
11
+ error?: string | undefined;
12
+ class?: string;
13
+ }
14
+ declare const LiteFileUpload: import("svelte").Component<Props, {}, "">;
15
+ type LiteFileUpload = ReturnType<typeof LiteFileUpload>;
16
+ export default LiteFileUpload;
@@ -1,6 +1,9 @@
1
1
  <script lang="ts">
2
2
  import type { CrudOperator, FieldDefinition, Filter } from '@svadmin/core';
3
+ import { filterOperatorsForField as operatorsForField, isNumericFilterField as isNumericField,
4
+ isCollectionFilterOperator, isFilterCollectionValue } from '@svadmin/core';
3
5
  import { t } from '@svadmin/core/i18n';
6
+ import { readFilterForm } from '../filter-form.js';
4
7
 
5
8
  export interface FilterRuleItem {
6
9
  id: string;
@@ -9,10 +12,13 @@
9
12
  value: unknown;
10
13
  }
11
14
 
15
+ type FilterNode =
16
+ | { kind: 'group'; id: string; operator: 'and' | 'or'; children: FilterNode[] }
17
+ | { kind: 'rule'; id: string; field: string; operator: CrudOperator; value: unknown; readonly?: boolean };
18
+
12
19
  interface Props {
13
20
  fields?: FieldDefinition[];
14
21
  filters?: Filter[];
15
- logicalOperator?: 'and' | 'or';
16
22
  action?: string;
17
23
  method?: 'GET' | 'POST';
18
24
  disabled?: boolean;
@@ -22,184 +28,191 @@
22
28
  let {
23
29
  fields = [],
24
30
  filters = [],
25
- logicalOperator = 'and',
26
31
  action = '',
32
+ method = 'GET',
27
33
  disabled = false,
34
+ onApply,
28
35
  }: Props = $props();
36
+ let validationError = $state<string | undefined>();
29
37
 
30
- const operatorOptions: { value: CrudOperator; label: string }[] = [
38
+ const instanceId = $props.id();
39
+ const formId = `${instanceId}-filter-form`;
40
+ const operatorOptions = $derived<{ value: CrudOperator; label: string }[]>([
31
41
  { value: 'eq', label: '= 等于' },
32
42
  { value: 'ne', label: '!= 不等于' },
33
43
  { value: 'contains', label: '包含' },
34
44
  { value: 'ncontains', label: '不包含' },
45
+ { value: 'startswith', label: '开头为' },
46
+ { value: 'endswith', label: '结尾为' },
35
47
  { value: 'gt', label: '> 大于' },
36
48
  { value: 'gte', label: '>= 大于等于' },
37
49
  { value: 'lt', label: '< 小于' },
38
50
  { value: 'lte', label: '<= 小于等于' },
39
- { value: 'null', label: '为空 (null)' },
51
+ { value: 'in', label: t('filter.in') },
52
+ { value: 'nin', label: t('filter.nin') },
53
+ { value: 'between', label: t('filter.between') },
54
+ { value: 'nbetween', label: t('filter.nbetween') },
55
+ { value: 'null', label: '为空' },
40
56
  { value: 'nnull', label: '不为空' },
41
- ];
57
+ ]);
42
58
 
43
- // Flatten initial filters or fallback to 1 empty rule
44
- const initialRules = $derived.by(() => {
45
- if (filters.length > 0) {
46
- return filters.map((f, i) => ({
47
- id: `rule_${i}`,
48
- field: 'field' in f ? String(f.field) : (fields[0]?.key ?? ''),
49
- operator: ('operator' in f ? f.operator : 'eq') as CrudOperator,
50
- value: 'value' in f ? f.value : '',
51
- }));
52
- }
53
- return [
54
- {
55
- id: 'rule_0',
56
- field: fields[0]?.key ?? '',
57
- operator: 'eq' as CrudOperator,
58
- value: '',
59
- },
60
- ];
61
- });
62
- </script>
59
+ const availableFields = $derived(fields.filter(field => field.filterable !== false));
63
60
 
64
- <div class="lite-filter-builder lite-form-group">
65
- <div class="lite-filter-header">
66
- <div class="lite-filter-title">
67
- <strong>{t('common.filters') || '高级多条件筛选'}</strong>
68
- <select
69
- name="logical_op"
70
- class="lite-select lite-select-sm"
71
- style="width: 120px; display: inline-block; margin-left: 8px;"
72
- value={logicalOperator}
73
- {disabled}
74
- >
75
- <option value="and">符合全部 (AND)</option>
76
- <option value="or">符合任一 (OR)</option>
77
- </select>
78
- </div>
79
- <div>
80
- <button type="submit" name="_action" value="add_filter_rule" class="lite-btn lite-btn-sm" {disabled}>
81
- + 添加条件
82
- </button>
83
- </div>
84
- </div>
61
+ function nodeFromFilter(filter: Filter, path: string): FilterNode {
62
+ if ('field' in filter) {
63
+ const field = availableFields.find(candidate => candidate.key === filter.field);
64
+ const editable = field !== undefined
65
+ && operatorsForField(field).includes(filter.operator)
66
+ && (filter.operator === 'null' || filter.operator === 'nnull'
67
+ ? filter.value === null
68
+ : isCollectionFilterOperator(filter.operator)
69
+ ? isFilterCollectionValue(field, filter.operator, filter.value)
70
+ : isNumericField(field)
71
+ ? typeof filter.value === 'number' && Number.isFinite(filter.value)
72
+ : field.type === 'boolean'
73
+ ? typeof filter.value === 'boolean'
74
+ : field.type === 'select'
75
+ ? field.options?.some(option => Object.is(option.value, filter.value)) === true
76
+ : typeof filter.value === 'string');
77
+ return { kind: 'rule', id: `rule-${path}`, field: filter.field, operator: filter.operator, value: filter.value, readonly: !editable };
78
+ }
79
+ return {
80
+ kind: 'group',
81
+ id: `group-${path}`,
82
+ operator: filter.operator,
83
+ children: filter.value.map((child, index) => nodeFromFilter(child, `${path}-${index}`)),
84
+ };
85
+ }
85
86
 
86
- <div class="lite-filter-rules">
87
- {#each initialRules as rule, idx (rule.id)}
88
- <div class="lite-filter-row">
89
- <!-- Field select -->
90
- <select
91
- name="filter_field_{idx}"
92
- class="lite-select lite-filter-field"
93
- value={rule.field}
94
- {disabled}
95
- >
96
- {#each fields as fld (fld.key)}
97
- <option value={fld.key}>{fld.label || fld.key}</option>
98
- {/each}
99
- </select>
87
+ const nodes = $derived(filters.map((filter, index) => nodeFromFilter(filter, String(index))));
100
88
 
101
- <!-- Operator select -->
102
- <select
103
- name="filter_op_{idx}"
104
- class="lite-select lite-filter-op"
105
- value={rule.operator}
106
- {disabled}
107
- >
108
- {#each operatorOptions as op (op.value)}
109
- <option value={op.value}>{op.label}</option>
110
- {/each}
111
- </select>
89
+ function readValue(node: FilterNode): unknown {
90
+ return node.kind === 'rule' ? node.value : undefined;
91
+ }
112
92
 
113
- <!-- Value input -->
114
- {#if rule.operator !== 'null' && rule.operator !== 'nnull'}
115
- <input
116
- type="text"
117
- name="filter_val_{idx}"
118
- class="lite-input lite-filter-val"
119
- value={String(rule.value ?? '')}
120
- placeholder="输入筛选值..."
121
- {disabled}
122
- />
123
- {/if}
93
+ function handleSubmit(event: SubmitEvent): void {
94
+ if (!onApply) return;
95
+ const submitter = event.submitter;
96
+ if (submitter instanceof HTMLButtonElement && submitter.name === '_action') return;
97
+ event.preventDefault();
98
+ try {
99
+ validationError = undefined;
100
+ onApply(readFilterForm(filters, fields, new FormData(event.currentTarget as HTMLFormElement)));
101
+ } catch (error) {
102
+ validationError = error instanceof Error ? error.message : 'Invalid filters';
103
+ }
104
+ }
124
105
 
125
- <!-- Remove row button -->
126
- <button
127
- type="submit"
128
- name="_action"
129
- value="remove_filter_rule_{idx}"
130
- class="lite-btn lite-btn-sm lite-btn-danger"
131
- {disabled}
132
- title="删除此行条件"
133
- >
134
- ✕
135
- </button>
136
- </div>
137
- {/each}
138
- </div>
106
+ </script>
139
107
 
140
- <div class="lite-filter-footer">
141
- <button type="submit" class="lite-btn lite-btn-primary">
108
+ <div class="lite-filter-builder lite-form-group">
109
+ <div class="lite-filter-header">
110
+ <strong>{t('common.filters') || '高级多条件筛选'}</strong>
111
+ <button type="submit" form={formId} class="lite-btn lite-btn-primary lite-btn-sm" disabled={disabled}>
142
112
  应用筛选
143
113
  </button>
144
- <a href={action || '?'} class="lite-btn" style="margin-left: 8px;">
145
- 重置
146
- </a>
147
114
  </div>
115
+
116
+ <form id={formId} {action} {method} class="lite-filter-form" onsubmit={handleSubmit}>
117
+ {#if validationError}<p role="alert" class="lite-error">{validationError}</p>{/if}
118
+ {#snippet renderNode(node: FilterNode, path: string, isRoot = false)}
119
+ {#if node.kind === 'group'}
120
+ <fieldset class="lite-filter-group" data-testid={isRoot ? 'lite-filter-root' : 'lite-filter-group'}>
121
+ <legend>
122
+ <label>
123
+ 逻辑组
124
+ <select name={`filters[${path}][operator]`} value={node.operator} {disabled}>
125
+ <option value="and">AND (且)</option>
126
+ <option value="or">OR (或)</option>
127
+ </select>
128
+ </label>
129
+ </legend>
130
+ {#each node.children as child, index (child.id)}
131
+ {@render renderNode(child, `${path}.value.${index}`)}
132
+ {:else}
133
+ <p class="lite-hint">暂无筛选条件</p>
134
+ {/each}
135
+ <button type="submit" name="_action" value={`add_filter_rule:${path}`} class="lite-btn lite-btn-sm" {disabled}>添加条件</button>
136
+ <button type="submit" name="_action" value={`add_filter_group:${path}`} class="lite-btn lite-btn-sm" {disabled}>添加条件组</button>
137
+ </fieldset>
138
+ {:else}
139
+ <div class="lite-filter-row" data-testid="lite-filter-rule">
140
+ {#if node.readonly}
141
+ <output aria-label={t('filter.readonly')}>{node.field} {node.operator} {JSON.stringify(node.value)}</output>
142
+ <!-- 严格解析器拒绝非协议属性,防止原生文本传输改变历史条件类型。 -->
143
+ <input type="hidden" name={`filters[${path}][invalid]`} value="true" {disabled} />
144
+ <input type="hidden" name={`filters[${path}][field]`} value={node.field} {disabled} />
145
+ <input type="hidden" name={`filters[${path}][operator]`} value={node.operator} {disabled} />
146
+ {#if node.operator !== 'null' && node.operator !== 'nnull'}
147
+ <input type="hidden" name={`filters[${path}][value]`} value={typeof node.value === 'string' ? node.value : JSON.stringify(node.value)} {disabled} />
148
+ {/if}
149
+ {:else}
150
+ <select name={`filters[${path}][field]`} value={node.field} {disabled}>
151
+ {#each availableFields as field (field.key)}<option value={field.key}>{field.label || field.key}</option>{/each}
152
+ </select>
153
+ <select name={`filters[${path}][operator]`} value={node.operator} {disabled}>
154
+ {#each operatorOptions.filter(operator => operatorsForField(availableFields.find(field => field.key === node.field)).includes(operator.value)) as operator (operator.value)}<option value={operator.value}>{operator.label}</option>{/each}
155
+ </select>
156
+ {#if node.operator !== 'null' && node.operator !== 'nnull'}
157
+ {#if isCollectionFilterOperator(node.operator) && Array.isArray(node.value)}
158
+ {@const field = availableFields.find(field => field.key === node.field)}
159
+ <div class="lite-filter-values">
160
+ {#each node.value as item, index (index)}
161
+ {@const label = node.operator === 'between' || node.operator === 'nbetween'
162
+ ? t(index === 0 ? 'filter.rangeStart' : 'filter.rangeEnd')
163
+ : t('filter.collectionItem', { number: String(index + 1) })}
164
+ {#if field?.type === 'select'}
165
+ <select aria-label={label} name={`filters[${path}][values.${index}]`} value={JSON.stringify(item)} {disabled}>
166
+ {#each field.options ?? [] as option, optionIndex (optionIndex)}
167
+ <option value={JSON.stringify(option.value)} disabled={option.disabled}>{option.label}</option>
168
+ {/each}
169
+ </select>
170
+ {:else if field?.type === 'boolean'}
171
+ <select aria-label={label} name={`filters[${path}][values.${index}]`} value={String(item)} {disabled}>
172
+ <option value="true">{t('common.yes')}</option>
173
+ <option value="false">{t('common.no')}</option>
174
+ </select>
175
+ {:else}
176
+ <input aria-label={label} name={`filters[${path}][values.${index}]`}
177
+ type={isNumericField(field) ? 'number' : 'text'} step="any" value={String(item)} {disabled} />
178
+ {/if}
179
+ {/each}
180
+ </div>
181
+ {:else if availableFields.find(field => field.key === node.field)?.type === 'boolean'}
182
+ <select name={`filters[${path}][value]`} value={String(readValue(node) ?? '')} {disabled}>
183
+ <option value="">请选择</option><option value="true">是</option><option value="false">否</option>
184
+ </select>
185
+ {:else}
186
+ <input name={`filters[${path}][value]`} type={isNumericField(availableFields.find(field => field.key === node.field)) ? 'number' : 'text'} value={String(readValue(node) ?? '')} {disabled} />
187
+ {/if}
188
+ {/if}
189
+ {/if}
190
+ <button type="submit" name="_action" value={`remove_filter_rule:${path}`} class="lite-btn lite-btn-sm lite-btn-danger" {disabled} title="删除此行条件">删除</button>
191
+ </div>
192
+ {/if}
193
+ {/snippet}
194
+
195
+ {#if nodes.length === 0}
196
+ <p class="lite-hint">暂无筛选条件,请先添加规则。</p>
197
+ <button type="submit" name="_action" value="add_filter_rule:root" class="lite-btn lite-btn-sm" {disabled}>添加条件</button>
198
+ {:else}
199
+ {#each nodes as node, index (node.id)}
200
+ {@render renderNode(node, String(index), node.kind === 'group' && nodes.length === 1)}
201
+ {/each}
202
+ {/if}
203
+ </form>
148
204
  </div>
149
205
 
150
206
  <style>
151
- .lite-filter-builder {
152
- border: 1px solid #cbd5e1;
153
- border-radius: 6px;
154
- padding: 14px;
155
- background: #f8fafc;
156
- margin-bottom: 16px;
157
- }
158
- .lite-filter-header {
159
- display: flex;
160
- justify-content: space-between;
161
- align-items: center;
162
- margin-bottom: 12px;
163
- }
164
- .lite-filter-rules {
165
- display: flex;
166
- flex-direction: column;
167
- margin-bottom: 12px;
168
- }
169
- .lite-filter-row {
170
- display: flex;
171
- align-items: center;
172
- background: #ffffff;
173
- padding: 8px;
174
- border: 1px solid #e2e8f0;
175
- border-radius: 4px;
176
- margin-bottom: 8px;
177
- }
178
- .lite-filter-row:last-child {
179
- margin-bottom: 0;
180
- }
181
- .lite-filter-row > * {
182
- margin-right: 8px;
183
- }
184
- .lite-filter-row > *:last-child {
185
- margin-right: 0;
186
- }
187
- .lite-filter-field {
188
- flex: 2;
189
- min-width: 130px;
190
- }
191
- .lite-filter-op {
192
- flex: 2;
193
- min-width: 120px;
194
- }
195
- .lite-filter-val {
196
- flex: 3;
197
- min-width: 140px;
198
- }
199
- .lite-filter-footer {
200
- display: flex;
201
- align-items: center;
202
- border-top: 1px solid #e2e8f0;
203
- padding-top: 10px;
204
- }
207
+ .lite-filter-builder { border: 1px solid #cbd5e1; border-radius: 6px; padding: 14px; background: #f8fafc; margin-bottom: 16px; }
208
+ .lite-filter-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
209
+ .lite-filter-group { border: 1px solid #e2e8f0; border-radius: 4px; padding: 10px; margin-bottom: 8px; background: #fff; }
210
+ .lite-filter-row { display: flex; align-items: center; padding: 8px; border: 1px solid #e2e8f0; border-radius: 4px; margin-bottom: 8px; background: #fff; }
211
+ .lite-filter-row > * { min-width: 120px; flex: 1; }
212
+ .lite-filter-row > * + * { margin-left: 8px; }
213
+ .lite-filter-row .lite-btn { flex: 0 0 auto; }
214
+ .lite-filter-values { display: flex; flex-wrap: wrap; min-width: 0; }
215
+ .lite-filter-values > * { width: 160px; max-width: 100%; box-sizing: border-box; margin: 0 8px 8px 0; }
216
+ .lite-hint { color: #64748b; font-size: 12px; }
217
+ .lite-btn { margin-right: 6px; }
205
218
  </style>
@@ -8,7 +8,6 @@ export interface FilterRuleItem {
8
8
  interface Props {
9
9
  fields?: FieldDefinition[];
10
10
  filters?: Filter[];
11
- logicalOperator?: 'and' | 'or';
12
11
  action?: string;
13
12
  method?: 'GET' | 'POST';
14
13
  disabled?: boolean;
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { useTranslation } from '@svadmin/core/i18n';
3
+ import { GANTT_LIMITS, validateGantt } from '@svadmin/core/gantt';
2
4
  export interface GanttTask {
3
5
  id: string;
4
6
  title: string;
@@ -6,12 +8,18 @@
6
8
  durationDays: number;
7
9
  progress?: number;
8
10
  status?: 'planned' | 'in_progress' | 'completed' | 'delayed';
11
+ dependencies?: string[];
12
+ milestone?: boolean;
9
13
  }
10
14
 
11
15
  interface Props {
12
16
  tasks?: GanttTask[];
13
17
  totalDays?: number;
14
18
  dayLabelPrefix?: string;
19
+ loading?: boolean;
20
+ error?: string | undefined;
21
+ retryHref?: string;
22
+ ariaLabel?: string;
15
23
  class?: string;
16
24
  }
17
25
 
@@ -19,48 +27,73 @@
19
27
  tasks = [],
20
28
  totalDays = 14,
21
29
  dayLabelPrefix = 'D',
30
+ loading = false,
31
+ error,
32
+ retryHref,
33
+ ariaLabel,
22
34
  class: className = '',
23
35
  }: Props = $props();
24
36
 
25
37
  const days = $derived(
26
- Array.from({ length: totalDays }, (_, i) => `${dayLabelPrefix}${i + 1}`)
38
+ Array.from({ length: Number.isSafeInteger(totalDays) && totalDays > 0 && totalDays <= GANTT_LIMITS.days ? totalDays : 0 }, (_, i) => `${dayLabelPrefix}${i + 1}`)
27
39
  );
40
+ const i18n = useTranslation();
41
+ const validationError = $derived(validateGantt(loading || error ? [] : tasks, totalDays));
42
+ const displayError = $derived(error || (validationError ? i18n.t(`gantt.${validationError}`) : ''));
43
+ const safeRetryHref = $derived(retryHref?.startsWith('/') && !/^\/[\\/]/.test(retryHref)
44
+ && !retryHref.includes('\\')
45
+ && ![...retryHref].some(char => char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127) ? retryHref : undefined);
28
46
  </script>
29
47
 
30
- <div class="sv-lite-gantt-container {className}">
48
+ <div class="sv-lite-gantt-container {className}" aria-busy={loading}>
31
49
  <div class="sv-lite-gantt-title">
32
- <strong>Project Schedule</strong> ({tasks.length} tasks / {totalDays} days)
50
+ <strong>{i18n.t('gantt.title')}</strong> ({i18n.t('gantt.summary', { tasks: tasks.length, days: totalDays })})
33
51
  </div>
34
52
 
35
- <table class="sv-lite-gantt-table">
53
+ {#if loading}
54
+ <div role="status">{i18n.t('common.loading')}</div>
55
+ {:else if displayError}
56
+ <div role="alert">{displayError}</div>
57
+ {#if error && safeRetryHref}<a href={safeRetryHref}>{i18n.t('common.retry')}</a>{/if}
58
+ {:else if tasks.length === 0}
59
+ <div role="status">{i18n.t('gantt.noData')}</div>
60
+ {:else}
61
+ <table class="sv-lite-gantt-table" aria-label={ariaLabel ?? i18n.t('gantt.title')}>
36
62
  <thead>
37
63
  <tr>
38
- <th class="sv-lite-th-task">Task</th>
39
- <th class="sv-lite-th-status">Status</th>
64
+ <th scope="col" class="sv-lite-th-task">{i18n.t('gantt.task')}</th>
65
+ <th scope="col" class="sv-lite-th-status">{i18n.t('gantt.status')}</th>
40
66
  {#each days as day (day)}
41
- <th class="sv-lite-th-day">{day}</th>
67
+ <th scope="col" class="sv-lite-th-day">{day}</th>
42
68
  {/each}
43
69
  </tr>
44
70
  </thead>
45
71
  <tbody>
46
72
  {#each tasks as task (task.id)}
47
73
  <tr>
48
- <td class="sv-lite-td-task">{task.title}</td>
74
+ <th scope="row" class="sv-lite-td-task" aria-label={`${task.title}, ${task.milestone
75
+ ? i18n.t('gantt.milestone', { day: task.startDay + 1 })
76
+ : i18n.t('gantt.range', { start: task.startDay + 1, end: task.startDay + task.durationDays })}`}>{task.title}</th>
49
77
  <td class="sv-lite-td-status">
50
- <span class="sv-lite-gantt-badge sv-lite-{task.status ?? 'planned'}">{task.status ?? 'planned'}</span>
78
+ <span class="sv-lite-gantt-badge sv-lite-{task.status ?? 'planned'}">{i18n.t(`gantt.${task.status ?? 'planned'}`)}</span>
51
79
  </td>
52
80
  {#each days as _, dIdx (dIdx)}
53
81
  {@const isCovered = dIdx >= task.startDay && dIdx < task.startDay + task.durationDays}
54
- <td class="sv-lite-td-cell {isCovered ? 'sv-lite-cell-active sv-lite-bg-' + (task.status ?? 'planned') : ''}">
82
+ {@const isMilestone = task.milestone && dIdx === task.startDay}
83
+ <td aria-label={isMilestone ? `${task.title}, ${i18n.t('gantt.milestone', { day: dIdx + 1 })}`
84
+ : isCovered ? `${task.title}, ${days[dIdx]}, ${i18n.t(`gantt.${task.status ?? 'planned'}`)}` : undefined}
85
+ class="sv-lite-td-cell {isCovered ? 'sv-lite-cell-active sv-lite-bg-' + (task.status ?? 'planned') : ''}">
55
86
  {#if isCovered && dIdx === task.startDay && task.progress !== undefined}
56
87
  <span class="sv-lite-cell-progress">{task.progress}%</span>
57
88
  {/if}
89
+ {#if isMilestone}<span aria-hidden="true">◆</span>{/if}
58
90
  </td>
59
91
  {/each}
60
92
  </tr>
61
93
  {/each}
62
94
  </tbody>
63
95
  </table>
96
+ {/if}
64
97
  </div>
65
98
 
66
99
  <style>
@@ -5,11 +5,17 @@ export interface GanttTask {
5
5
  durationDays: number;
6
6
  progress?: number;
7
7
  status?: 'planned' | 'in_progress' | 'completed' | 'delayed';
8
+ dependencies?: string[];
9
+ milestone?: boolean;
8
10
  }
9
11
  interface Props {
10
12
  tasks?: GanttTask[];
11
13
  totalDays?: number;
12
14
  dayLabelPrefix?: string;
15
+ loading?: boolean;
16
+ error?: string | undefined;
17
+ retryHref?: string;
18
+ ariaLabel?: string;
13
19
  class?: string;
14
20
  }
15
21
  declare const LiteGanttChart: import("svelte").Component<Props, {}, "">;