agentcss 0.16.1 → 0.24.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/02-primitives/choice/choice.css +31 -2
- package/02-primitives/input/input.css +14 -0
- package/02-primitives/progress/progress.css +56 -0
- package/02-primitives/skeleton/skeleton.css +18 -0
- package/02-primitives/switch/switch.css +61 -2
- package/02-primitives/timeline/README.md +70 -0
- package/02-primitives/timeline/demo.html +161 -0
- package/02-primitives/timeline/timeline.css +152 -0
- package/02-primitives/upload/README.md +142 -0
- package/02-primitives/upload/demo.html +318 -0
- package/02-primitives/upload/upload.css +490 -0
- package/02-primitives/upload/upload.js +676 -0
- package/03-patterns/drawer/drawer.js +30 -5
- package/04-recipes/app-shell/README.md +272 -0
- package/04-recipes/app-shell/app-shell.css +588 -0
- package/04-recipes/app-shell/demo.html +322 -0
- package/04-recipes/app-shell/shell-nav.js +288 -0
- package/04-recipes/table/table.css +22 -0
- package/AGENTS.md +8 -0
- package/API.md +4 -0
- package/CHANGELOG.md +736 -0
- package/CONTRIBUTING.md +22 -1
- package/README.md +17 -5
- package/README.zh-CN.md +16 -6
- package/START-HERE.md +12 -7
- package/ai/START-HERE.md +37 -3
- package/ai/behaviors.json +32 -0
- package/ai/cli.js +183 -25
- package/ai/components.json +185 -7
- package/ai/components.meta.json +13 -0
- package/ai/pitfalls-writing.json +119 -0
- package/ai/pitfalls.json +193 -0
- package/dist/02-primitives/choice/choice.css +1 -1
- package/dist/02-primitives/input/input.css +1 -1
- package/dist/02-primitives/progress/progress.css +1 -1
- package/dist/02-primitives/skeleton/skeleton.css +1 -1
- package/dist/02-primitives/switch/switch.css +1 -1
- package/dist/02-primitives/timeline/timeline.css +1 -0
- package/dist/02-primitives/upload/upload.css +1 -0
- package/dist/02-primitives/upload/upload.js +515 -0
- package/dist/03-patterns/drawer/drawer.js +14 -4
- package/dist/04-recipes/app-shell/app-shell.css +1 -0
- package/dist/04-recipes/app-shell/shell-nav.js +173 -0
- package/dist/04-recipes/table/table.css +1 -1
- package/dist/COSTS.md +12 -10
- package/docs/ACCESSIBILITY.md +20 -7
- package/docs/AI-CONTRACT.md +2 -0
- package/docs/BUDGET.md +179 -0
- package/docs/CONVENTIONS.md +3 -0
- package/docs/STABILITY.md +126 -0
- package/esm/index.mjs +2 -0
- package/esm/upload.mjs +7 -0
- package/index.html +5 -2
- package/llms-full.txt +153 -0
- package/llms.txt +55 -0
- package/package.json +4 -1
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* upload.js — 文件上传的增强层(拖放 / 多文件 / 校验 / 移除)
|
|
3
|
+
*
|
|
4
|
+
* ==========================================================================
|
|
5
|
+
* 🔴 真值方向:`<input>.files` 是唯一真值,文件列表是它的**镜像**
|
|
6
|
+
* --------------------------------------------------------------------------
|
|
7
|
+
* 拖放不是"第二条上传通道"。它做三件事:
|
|
8
|
+
* ① 把拖入的 File 塞进 `input.files`(经 `DataTransfer`)
|
|
9
|
+
* ② 重渲染列表
|
|
10
|
+
* ③ 派发 `fl-upload`
|
|
11
|
+
* 于是 `<form>` 序列化、`FormData`、原生 `accept` 语义全部不用重写。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ 反过来做(自己维护一个 files 数组,提交时再拼)是这类组件最常见的错:
|
|
14
|
+
* 两条通道的校验规则迟早不一致,而且**表单提交时文件会丢** ——
|
|
15
|
+
* 因为浏览器只认 `input.files`。
|
|
16
|
+
*
|
|
17
|
+
* ⇒ 由此推出一条必须接受的语义:**再次选择/拖入会替换整个列表**,
|
|
18
|
+
* 与原生 `<input type=file multiple>` 完全一致。不做"追加",
|
|
19
|
+
* 否则同一个控件里存在两种累加规则。
|
|
20
|
+
*
|
|
21
|
+
* ==========================================================================
|
|
22
|
+
* 🔴 校验只有一份实现(`_validate`),两条入口都调它
|
|
23
|
+
* --------------------------------------------------------------------------
|
|
24
|
+
* "拖放"和"原生选择"是两个入口,但校验必须是**同一段代码**。
|
|
25
|
+
* 写成两遍的后果不是"难看",是它们会**漂移**:某天给拖放加了大小限制,
|
|
26
|
+
* 忘了给原生选择加 —— 于是同一页面上,拖进去的文件被拒、点选进去的通过。
|
|
27
|
+
* 这种 bug 只有用户会碰到,测试永远碰不到(两条路各自都是对的)。
|
|
28
|
+
*
|
|
29
|
+
* ==========================================================================
|
|
30
|
+
* 🔴 `DataTransfer` 不存在时,拖放必须**明确拒绝**,不能装作成功
|
|
31
|
+
* --------------------------------------------------------------------------
|
|
32
|
+
* 老 Safari(< 14.1)没有 `new DataTransfer()`,于是 `input.files` **不可写**。
|
|
33
|
+
* 此时如果照样渲染列表、照样派发事件,用户会看到"文件进去了",
|
|
34
|
+
* 点提交却什么都没有 —— 而且没有任何报错。
|
|
35
|
+
* ⇒ 本实现直接提示"当前浏览器不支持拖放,请点选择文件"并**不接收**。
|
|
36
|
+
* 少一个功能,但不存在假成功。
|
|
37
|
+
*
|
|
38
|
+
* ==========================================================================
|
|
39
|
+
* 无依赖 · ES5(不用 let/const/箭头函数/模板串)
|
|
40
|
+
* ==========================================================================
|
|
41
|
+
*/
|
|
42
|
+
(function (global) {
|
|
43
|
+
'use strict';
|
|
44
|
+
|
|
45
|
+
/* ==== BEHAVIOR INJECT BEGIN: emit ==== */
|
|
46
|
+
var flEmit = function (el, name, detail) {
|
|
47
|
+
if (!el || !el.dispatchEvent) return null;
|
|
48
|
+
var type = name.indexOf('fl-') === 0 ? name : 'fl-' + name;
|
|
49
|
+
var ev = null;
|
|
50
|
+
/* 老 WebView 没有 CustomEvent 构造函数 ⇒ 兜底走 createEvent(理由见文件头)*/
|
|
51
|
+
if (typeof window.CustomEvent === 'function') {
|
|
52
|
+
try {
|
|
53
|
+
ev = new window.CustomEvent(type, {
|
|
54
|
+
detail: detail || null, bubbles: true, cancelable: false
|
|
55
|
+
});
|
|
56
|
+
} catch (e) { ev = null; }
|
|
57
|
+
}
|
|
58
|
+
if (!ev) {
|
|
59
|
+
try {
|
|
60
|
+
ev = document.createEvent('CustomEvent');
|
|
61
|
+
ev.initCustomEvent(type, true, false, detail || null);
|
|
62
|
+
} catch (e2) { return null; }
|
|
63
|
+
}
|
|
64
|
+
el.dispatchEvent(ev);
|
|
65
|
+
return ev;
|
|
66
|
+
};
|
|
67
|
+
/* ==== BEHAVIOR INJECT END: emit ==== */
|
|
68
|
+
|
|
69
|
+
var SVG_NS = 'http://www.w3.org/2000/svg';
|
|
70
|
+
|
|
71
|
+
/* 图标路径(viewBox 0 0 16 16,纯 stroke,无 fill)
|
|
72
|
+
⭐ 为什么用内联 SVG 而不是 CSS `mask` + `background-color`:
|
|
73
|
+
强制色模式下 background 被抹成 Canvas ⇒ 白底白图形,图标整个消失。
|
|
74
|
+
`stroke="currentColor"` 跟着文字色走,而文字色必然与背景不同 ⇒ 一定可见。
|
|
75
|
+
(0.17.0 的六类缺陷里有两类正是这个模式:table 排序箭头、input 错误图标。) */
|
|
76
|
+
var ICON = {
|
|
77
|
+
pending: 'M8 1.7a6.3 6.3 0 1 0 0 12.6A6.3 6.3 0 0 0 8 1.7zM8 4.6V8l2.3 1.5',
|
|
78
|
+
done: 'M3.3 8.4l3.1 3.1 6.3-6.9',
|
|
79
|
+
error: 'M8 1.7a6.3 6.3 0 1 0 0 12.6A6.3 6.3 0 0 0 8 1.7zM8 4.9v3.4M8 11.1v.5',
|
|
80
|
+
remove: 'M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6'
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
var STATE_TEXT = { pending: '待上传', done: '已完成', error: '失败' };
|
|
84
|
+
|
|
85
|
+
/* ---------------------------------------------------------------- 工具 */
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 人类可读的文件大小。
|
|
89
|
+
* 10KB / 10MB 以下给一位小数("3.4 KB"),以上取整;
|
|
90
|
+
* 整数结果去掉多余的 ".0"("1 KB" 而不是 "1.0 KB" —— 后者看起来像精度丢了)。
|
|
91
|
+
*/
|
|
92
|
+
function humanSize(n) {
|
|
93
|
+
if (typeof n !== 'number' || n < 0) return '';
|
|
94
|
+
if (n < 1024) return n + ' B';
|
|
95
|
+
if (n < 1024 * 1024) {
|
|
96
|
+
return (n < 10240 ? oneDecimal(n / 1024) : Math.round(n / 1024)) + ' KB';
|
|
97
|
+
}
|
|
98
|
+
return (n < 10 * 1048576 ? oneDecimal(n / 1048576) : Math.round(n / 1048576)) + ' MB';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function oneDecimal(v) {
|
|
102
|
+
return v.toFixed(1).replace(/\.0$/, '');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function trim(s) { return String(s == null ? '' : s).replace(/^\s+|\s+$/g, ''); }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `accept` 的第二道校验。
|
|
109
|
+
* 🔴 为什么必须有:`accept` 只约束**文件选择器**,拖放完全绕过它。
|
|
110
|
+
* 只写 `accept=".pdf"` 而拖入一个 .exe 时,浏览器不会拦 —— 这是规范行为。
|
|
111
|
+
*/
|
|
112
|
+
function matchesAccept(file, accept) {
|
|
113
|
+
if (!accept) return true;
|
|
114
|
+
var parts = String(accept).split(',');
|
|
115
|
+
var name = (file.name || '').toLowerCase();
|
|
116
|
+
var type = (file.type || '').toLowerCase();
|
|
117
|
+
for (var i = 0; i < parts.length; i++) {
|
|
118
|
+
var p = trim(parts[i]).toLowerCase();
|
|
119
|
+
if (!p) continue;
|
|
120
|
+
if (p.charAt(0) === '.') {
|
|
121
|
+
/* 扩展名必须比到末尾,否则 ".pdf" 会匹配上 "report.pdf.exe" */
|
|
122
|
+
if (name.length > p.length && name.slice(-p.length) === p) return true;
|
|
123
|
+
} else if (p.indexOf('/*') > 0) {
|
|
124
|
+
if (type.indexOf(p.slice(0, p.indexOf('/')) + '/') === 0) return true;
|
|
125
|
+
} else if (type === p) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 读当前 `input.files` 为普通数组(`FileList` 不能被 splice)。 */
|
|
133
|
+
function currentFiles(input) {
|
|
134
|
+
var out = [];
|
|
135
|
+
var fl = input && input.files;
|
|
136
|
+
if (!fl) return out;
|
|
137
|
+
for (var i = 0; i < fl.length; i++) out.push(fl[i]);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 把数组写回 `input.files`。
|
|
143
|
+
* @returns {boolean} 是否真的写进去了 —— 调用方必须检查,不能假设成功。
|
|
144
|
+
*/
|
|
145
|
+
function writeFiles(input, files) {
|
|
146
|
+
if (typeof global.DataTransfer !== 'function') return false;
|
|
147
|
+
try {
|
|
148
|
+
var dt = new global.DataTransfer();
|
|
149
|
+
for (var i = 0; i < files.length; i++) {
|
|
150
|
+
if (dt.items && dt.items.add) dt.items.add(files[i]);
|
|
151
|
+
}
|
|
152
|
+
input.files = dt.files;
|
|
153
|
+
return true;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function iconEl(name) {
|
|
160
|
+
var svg = document.createElementNS(SVG_NS, 'svg');
|
|
161
|
+
svg.setAttribute('viewBox', '0 0 16 16');
|
|
162
|
+
svg.setAttribute('fill', 'none');
|
|
163
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
164
|
+
svg.setAttribute('stroke-width', '1.6');
|
|
165
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
166
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
167
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
168
|
+
var path = document.createElementNS(SVG_NS, 'path');
|
|
169
|
+
path.setAttribute('d', ICON[name] || ICON.pending);
|
|
170
|
+
svg.appendChild(path);
|
|
171
|
+
return svg;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ---------------------------------------------------------------- 主体 */
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {Element} root `.upload` 元素
|
|
178
|
+
* @param {Object} [opts]
|
|
179
|
+
* @param {number} [opts.maxSize] 单文件上限(字节),0 = 不限
|
|
180
|
+
* @param {number} [opts.maxFiles] 文件数上限,0 = 不限
|
|
181
|
+
*/
|
|
182
|
+
function Upload(root, opts) {
|
|
183
|
+
var opt = opts || {};
|
|
184
|
+
|
|
185
|
+
this.root = root;
|
|
186
|
+
this.input = root.querySelector('.upload__input');
|
|
187
|
+
this.list = root.querySelector('.upload__list');
|
|
188
|
+
this.status = root.querySelector('.upload__status');
|
|
189
|
+
this.dropzone = root.querySelector('.upload__dropzone');
|
|
190
|
+
|
|
191
|
+
if (!this.input) {
|
|
192
|
+
throw new Error('upload: 缺少 .upload__input —— 没有它就没有真值,组件无法工作');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this.maxSize = opt.maxSize || parseInt(root.getAttribute('data-max-size'), 10) || 0;
|
|
196
|
+
this.maxFiles = opt.maxFiles || parseInt(root.getAttribute('data-max-files'), 10) || 0;
|
|
197
|
+
|
|
198
|
+
/* 进度是**唯一的非镜像状态**:它不在 `input.files` 里(文件本身没有进度)。
|
|
199
|
+
单独存,重建列表时按索引恢复,否则拖一次文件进度就全丢了。
|
|
200
|
+
语义:null = 待上传(无进度条)/ 0–99 = 上传中 / -1 = 已完成 */
|
|
201
|
+
this.progress = [];
|
|
202
|
+
this.rejected = [];
|
|
203
|
+
this._announceTimer = null;
|
|
204
|
+
this._dragDepth = 0;
|
|
205
|
+
|
|
206
|
+
var self = this;
|
|
207
|
+
this._onChange = function () { self._handleChange(); };
|
|
208
|
+
this.input.addEventListener('change', this._onChange);
|
|
209
|
+
|
|
210
|
+
if (this.dropzone) {
|
|
211
|
+
this._onDragOver = function (e) { self._handleDragOver(e); };
|
|
212
|
+
this._onDragEnter = function (e) { self._handleDragEnter(e); };
|
|
213
|
+
this._onDragLeave = function (e) { self._handleDragLeave(e); };
|
|
214
|
+
this._onDrop = function (e) { self._handleDrop(e); };
|
|
215
|
+
this.dropzone.addEventListener('dragover', this._onDragOver);
|
|
216
|
+
this.dropzone.addEventListener('dragenter', this._onDragEnter);
|
|
217
|
+
this.dropzone.addEventListener('dragleave', this._onDragLeave);
|
|
218
|
+
this.dropzone.addEventListener('drop', this._onDrop);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* 初始渲染:HTML 里预置了已选文件(服务端回显场景)时把它画出来 */
|
|
222
|
+
this.render();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
Upload.prototype = {
|
|
226
|
+
constructor: Upload,
|
|
227
|
+
|
|
228
|
+
/* ================================================================ 拖放 */
|
|
229
|
+
|
|
230
|
+
_handleDragOver: function (e) {
|
|
231
|
+
/* 不 preventDefault ⇒ 浏览器认为"这个区域不接受拖放" ⇒ 光标变成禁放,
|
|
232
|
+
且 drop 永远不会触发。这是「拖放没反应」的第一大原因,而且完全不报错。 */
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
if (e.dataTransfer) {
|
|
235
|
+
/* 只要"复制"语义:默认在部分系统上显示"移动",暗示原文件会被拿走 */
|
|
236
|
+
try { e.dataTransfer.dropEffect = 'copy'; } catch (err) { /* 只读环境 */ }
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
_handleDragEnter: function (e) {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
this._dragDepth++;
|
|
243
|
+
this.setState('dragover');
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
_handleDragLeave: function (e) {
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
/* dragenter/dragleave 必须**配对计数**:指针移到子元素(svg / span)上时,
|
|
249
|
+
浏览器先发 dragleave(父) 再发 dragenter(子)。直接按事件切状态会闪。 */
|
|
250
|
+
this._dragDepth--;
|
|
251
|
+
if (this._dragDepth <= 0) {
|
|
252
|
+
this._dragDepth = 0;
|
|
253
|
+
this.setState('');
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
_handleDrop: function (e) {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
this._dragDepth = 0;
|
|
260
|
+
this.setState('');
|
|
261
|
+
|
|
262
|
+
var dt = e.dataTransfer;
|
|
263
|
+
if (!dt) return;
|
|
264
|
+
|
|
265
|
+
/* 🔴 目录:拖一个文件夹进来时 `files` 里会有一个 size=0 的条目,
|
|
266
|
+
直接收下就得到"名字对但内容为空"的文件,上传后才发现。
|
|
267
|
+
⇒ 用 `webkitGetAsEntry` 判目录并明确拒绝。 */
|
|
268
|
+
var dropped = [];
|
|
269
|
+
var dirCount = 0;
|
|
270
|
+
if (dt.items && dt.items.length) {
|
|
271
|
+
for (var i = 0; i < dt.items.length; i++) {
|
|
272
|
+
var it = dt.items[i];
|
|
273
|
+
if (it.kind !== 'file') continue;
|
|
274
|
+
var entry = it.webkitGetAsEntry && it.webkitGetAsEntry();
|
|
275
|
+
if (entry && entry.isDirectory) { dirCount++; continue; }
|
|
276
|
+
var f = it.getAsFile && it.getAsFile();
|
|
277
|
+
if (f) dropped.push(f);
|
|
278
|
+
}
|
|
279
|
+
} else if (dt.files) {
|
|
280
|
+
for (var j = 0; j < dt.files.length; j++) dropped.push(dt.files[j]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!dropped.length && !dirCount) return;
|
|
284
|
+
this.add(dropped, dirCount);
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
/* ================================================================ 校验 */
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* **唯一的校验实现**。拖放与原生选择都调它。
|
|
291
|
+
* @returns {{keep: File[], rejected: Array}}
|
|
292
|
+
*/
|
|
293
|
+
_validate: function (incoming) {
|
|
294
|
+
var keep = [];
|
|
295
|
+
var rejected = [];
|
|
296
|
+
var maxFiles = this.maxFiles || (this.input.multiple ? 0 : 1);
|
|
297
|
+
var accept = this.input.getAttribute('accept');
|
|
298
|
+
|
|
299
|
+
for (var i = 0; i < incoming.length; i++) {
|
|
300
|
+
var f = incoming[i];
|
|
301
|
+
if (maxFiles && keep.length >= maxFiles) {
|
|
302
|
+
rejected.push({ file: f, reason: 'count', text: '最多 ' + maxFiles + ' 个文件' });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (this.maxSize && f.size > this.maxSize) {
|
|
306
|
+
rejected.push({ file: f, reason: 'size', text: '超过 ' + humanSize(this.maxSize) });
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (!matchesAccept(f, accept)) {
|
|
310
|
+
rejected.push({ file: f, reason: 'type', text: '类型不支持' });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
keep.push(f);
|
|
314
|
+
}
|
|
315
|
+
return { keep: keep, rejected: rejected };
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
/* ================================================================ 接收 */
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 收下一批文件(= 拖放入口,也是公开 API)。
|
|
322
|
+
*
|
|
323
|
+
* 原生选择走 `_handleChange`,两者最终都落到 `_validate` + `render`。
|
|
324
|
+
* 公开它是为了让"服务端回显 / 从别的控件导入"这类程序性设置
|
|
325
|
+
* 复用**同一条**校验路径,而不是在外面自己拼 `input.files`。
|
|
326
|
+
*
|
|
327
|
+
* @param {File[]} incoming
|
|
328
|
+
* @param {number} [dirCount] 被跳过的目录数(仅拖放路径会传)
|
|
329
|
+
* @returns {boolean} 是否至少收下了一个文件
|
|
330
|
+
*/
|
|
331
|
+
add: function (incoming, dirCount) {
|
|
332
|
+
var r = this._validate(incoming);
|
|
333
|
+
|
|
334
|
+
/* 目录不是"错误文件",是"不支持拖文件夹" —— 单独一句话,
|
|
335
|
+
否则用户会以为是自己文件夹里的内容有问题 */
|
|
336
|
+
if (dirCount) {
|
|
337
|
+
r.rejected.push({
|
|
338
|
+
file: { name: dirCount + ' 个文件夹', size: 0 },
|
|
339
|
+
reason: 'dir', text: '请拖入文件,不支持文件夹'
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!r.keep.length) {
|
|
344
|
+
/* 一个都没收:仍要把拒收项画出来,否则用户"拖了没反应" */
|
|
345
|
+
this.rejected = r.rejected;
|
|
346
|
+
this.render();
|
|
347
|
+
this.announce(r.rejected.length ? '未添加任何文件:' + r.rejected[0].text : '未添加任何文件');
|
|
348
|
+
flEmit(this.root, 'fl-upload', {
|
|
349
|
+
files: currentFiles(this.input), added: 0, rejected: r.rejected
|
|
350
|
+
});
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!writeFiles(this.input, r.keep)) {
|
|
355
|
+
/* 见文件头:宁可明说不可用,也不能装作收下了 */
|
|
356
|
+
this.rejected = [{ file: r.keep[0], reason: 'unsupported',
|
|
357
|
+
text: '当前浏览器不支持拖放,请点「选择文件」' }];
|
|
358
|
+
this.render();
|
|
359
|
+
this.announce('当前浏览器不支持拖放,请点选择文件');
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/* 换了文件集合 ⇒ 旧进度全部作废。不清会让新文件直接显示上一批的 42%。 */
|
|
364
|
+
this.progress = [];
|
|
365
|
+
this.rejected = r.rejected;
|
|
366
|
+
this.render();
|
|
367
|
+
this.announce(this.summary(r.keep.length, r.rejected.length));
|
|
368
|
+
flEmit(this.root, 'fl-upload', {
|
|
369
|
+
files: currentFiles(this.input), added: r.keep.length, rejected: r.rejected
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
/* 🔴 程序改 `input.files` **不会**触发原生 `change`(规范如此)。
|
|
373
|
+
这里**不**手动补发一个假 change —— 那会让本组件的 change 监听再次触发,
|
|
374
|
+
形成"拖一次渲染两遍"的循环。契约:程序性变更只派发 `fl-upload`。 */
|
|
375
|
+
return true;
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
/** 原生选择器的 change。 */
|
|
379
|
+
_handleChange: function () {
|
|
380
|
+
var files = currentFiles(this.input);
|
|
381
|
+
var r = this._validate(files);
|
|
382
|
+
|
|
383
|
+
/* 超限/类型不符的文件不能留在 input 里(会真的被提交)⇒ 剔除后写回 */
|
|
384
|
+
if (r.rejected.length) writeFiles(this.input, r.keep);
|
|
385
|
+
|
|
386
|
+
this.progress = [];
|
|
387
|
+
this.rejected = r.rejected;
|
|
388
|
+
this.render();
|
|
389
|
+
this.announce(this.summary(r.keep.length, r.rejected.length));
|
|
390
|
+
flEmit(this.root, 'fl-upload', {
|
|
391
|
+
files: currentFiles(this.input), added: r.keep.length, rejected: r.rejected
|
|
392
|
+
});
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
/* ================================================================ 渲染 */
|
|
396
|
+
|
|
397
|
+
/** 全量重渲染。只在"文件集合变了"时调用;单个移除走局部删除(见 removeAt)。 */
|
|
398
|
+
render: function () {
|
|
399
|
+
var files = currentFiles(this.input);
|
|
400
|
+
var keep = [];
|
|
401
|
+
|
|
402
|
+
/* `innerHTML=''` 在这里安全:列表内容随后全部由 createElement + textContent
|
|
403
|
+
重建,任何用户提供的字符串(文件名)都不会被当作 HTML 解析。 */
|
|
404
|
+
this.list.innerHTML = '';
|
|
405
|
+
|
|
406
|
+
for (var i = 0; i < files.length; i++) {
|
|
407
|
+
var pct = this.progress[i];
|
|
408
|
+
keep.push(pct === undefined ? null : pct);
|
|
409
|
+
this.list.appendChild(this.itemEl(files[i], i, null, pct));
|
|
410
|
+
}
|
|
411
|
+
this.progress = keep;
|
|
412
|
+
|
|
413
|
+
/* 拒收项追加在同一张表里(视觉同构,靠 data-state="error" 区分)。
|
|
414
|
+
⚠️ 不能在 render 开头清空 `this.rejected` —— 它是**本次渲染的输入**。 */
|
|
415
|
+
var rej = this.rejected || [];
|
|
416
|
+
for (var j = 0; j < rej.length; j++) {
|
|
417
|
+
this.list.appendChild(this.itemEl(rej[j].file, -1, rej[j].text, null));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
this.syncCount();
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* @param {File} file
|
|
425
|
+
* @param {number} index -1 表示拒收项(不可移除、无进度)
|
|
426
|
+
* @param {string|null} note 覆盖 meta 文本(拒收原因)
|
|
427
|
+
* @param {number|null} pct 进度;null = 无进度条
|
|
428
|
+
*/
|
|
429
|
+
itemEl: function (file, index, note, pct) {
|
|
430
|
+
var self = this;
|
|
431
|
+
var rejected = index < 0;
|
|
432
|
+
var state = rejected ? 'error' : (pct === -1 ? 'done' : 'pending');
|
|
433
|
+
|
|
434
|
+
var li = document.createElement('li');
|
|
435
|
+
li.className = 'upload__item';
|
|
436
|
+
li.setAttribute('data-state', state);
|
|
437
|
+
|
|
438
|
+
var mark = document.createElement('span');
|
|
439
|
+
mark.className = 'upload__mark';
|
|
440
|
+
mark.appendChild(iconEl(state === 'done' ? 'done' : state === 'error' ? 'error' : 'pending'));
|
|
441
|
+
/* 状态词给读屏(图标是 aria-hidden)—— 也是色盲用户的第二条线索 */
|
|
442
|
+
var sr = document.createElement('span');
|
|
443
|
+
sr.className = 'upload__status';
|
|
444
|
+
sr.textContent = STATE_TEXT[state] || '';
|
|
445
|
+
mark.appendChild(sr);
|
|
446
|
+
|
|
447
|
+
var name = document.createElement('span');
|
|
448
|
+
name.className = 'upload__name';
|
|
449
|
+
name.textContent = file.name || '—';
|
|
450
|
+
name.title = file.name || ''; /* 被截断时悬停看全名 */
|
|
451
|
+
|
|
452
|
+
var meta = document.createElement('span');
|
|
453
|
+
/* `num` 是本库统一的数字排印类(typography.css 的 .num:tabular-nums + tnum)。
|
|
454
|
+
🔴 用类而不是在 upload.css 里写 font-variant-numeric:
|
|
455
|
+
那会让"数字要等宽"出现第二份定义,numeric-gate 判这一条。
|
|
456
|
+
没引 typography.css 时 `num` 无效果 —— 退化成普通数字,不是错。 */
|
|
457
|
+
meta.className = 'upload__meta num';
|
|
458
|
+
meta.textContent = note || humanSize(file.size);
|
|
459
|
+
|
|
460
|
+
li.appendChild(mark);
|
|
461
|
+
li.appendChild(name);
|
|
462
|
+
li.appendChild(meta);
|
|
463
|
+
|
|
464
|
+
if (!rejected) {
|
|
465
|
+
/* 进度槽与移除按钮只属于"真的在 input 里"的文件。
|
|
466
|
+
🔴 进度槽**按需插入**,不是常驻空盒子:
|
|
467
|
+
`.upload__progress` 显式跨满整行 ⇒ 它一存在,grid 就多出一条隐式行。
|
|
468
|
+
常驻的话"待上传"的项会比"被拒"的项高 12px,列表看起来像有的行空着。
|
|
469
|
+
(0.17.0 实拍:54px vs 42px,一眼能看出不齐。) */
|
|
470
|
+
if (pct != null && pct >= 0) {
|
|
471
|
+
var bar0 = document.createElement('span');
|
|
472
|
+
bar0.className = 'upload__progress';
|
|
473
|
+
li.appendChild(bar0);
|
|
474
|
+
this.paintProgress(bar0, pct);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
var rm = document.createElement('button');
|
|
478
|
+
rm.className = 'upload__remove';
|
|
479
|
+
rm.type = 'button';
|
|
480
|
+
rm.setAttribute('aria-label', '移除 ' + (file.name || ''));
|
|
481
|
+
rm.appendChild(iconEl('remove'));
|
|
482
|
+
rm.addEventListener('click', function () { self.removeAt(index); });
|
|
483
|
+
li.appendChild(rm);
|
|
484
|
+
|
|
485
|
+
if (pct != null && pct >= 0) meta.textContent = '上传中 ' + Math.round(pct) + '%';
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return li;
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
paintProgress: function (bar, pct) {
|
|
492
|
+
/* 复用 progress 组件(demo 会同时引入 progress.css)。
|
|
493
|
+
没引入时这里退化成一条空 span —— 不报错,也不会把布局弄崩。 */
|
|
494
|
+
if (!bar || pct == null || pct < 0) return;
|
|
495
|
+
bar.innerHTML = '';
|
|
496
|
+
var track = document.createElement('span');
|
|
497
|
+
track.className = 'progress';
|
|
498
|
+
var fill = document.createElement('span');
|
|
499
|
+
fill.className = 'progress__bar';
|
|
500
|
+
fill.style.setProperty('--progress', Math.max(0, Math.min(100, pct)) + '%');
|
|
501
|
+
track.appendChild(fill);
|
|
502
|
+
bar.appendChild(track);
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
syncCount: function () {
|
|
506
|
+
/* 同步根上的 data-count:调用方用它做"没选文件就不让提交"这类判断。
|
|
507
|
+
空列表本身由 CSS `:empty` 收起,不需要额外类。 */
|
|
508
|
+
this.root.setAttribute('data-count', String(currentFiles(this.input).length));
|
|
509
|
+
},
|
|
510
|
+
|
|
511
|
+
setState: function (s) {
|
|
512
|
+
if (s) this.root.setAttribute('data-state', s);
|
|
513
|
+
else this.root.removeAttribute('data-state');
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
/* ================================================================ 进度 */
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 设置第 i 个文件的上传进度。
|
|
520
|
+
* @param {number} i 索引
|
|
521
|
+
* @param {number} pct 0–100;`-1` 或 `>=100` 表示完成(去掉进度条)
|
|
522
|
+
*/
|
|
523
|
+
setProgress: function (i, pct) {
|
|
524
|
+
var li = this.list.querySelectorAll('.upload__item')[i];
|
|
525
|
+
if (!li || li.getAttribute('data-state') === 'error') return;
|
|
526
|
+
var files = currentFiles(this.input);
|
|
527
|
+
var f = files[i];
|
|
528
|
+
var meta = li.querySelector('.upload__meta');
|
|
529
|
+
|
|
530
|
+
if (pct < 0 || pct >= 100) {
|
|
531
|
+
this.progress[i] = -1;
|
|
532
|
+
li.setAttribute('data-state', 'done');
|
|
533
|
+
var mark = li.querySelector('.upload__mark');
|
|
534
|
+
if (mark) {
|
|
535
|
+
mark.innerHTML = '';
|
|
536
|
+
mark.appendChild(iconEl('done'));
|
|
537
|
+
var sr = document.createElement('span');
|
|
538
|
+
sr.className = 'upload__status';
|
|
539
|
+
sr.textContent = STATE_TEXT.done;
|
|
540
|
+
mark.appendChild(sr);
|
|
541
|
+
}
|
|
542
|
+
if (meta && f) meta.textContent = humanSize(f.size);
|
|
543
|
+
var bar = li.querySelector('.upload__progress');
|
|
544
|
+
if (bar && bar.parentNode) bar.parentNode.removeChild(bar);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
this.progress[i] = pct;
|
|
549
|
+
li.setAttribute('data-state', 'pending');
|
|
550
|
+
var bar = li.querySelector('.upload__progress');
|
|
551
|
+
if (!bar) {
|
|
552
|
+
/* 第一次出现进度时才建槽(无进度时不留空行,见 itemEl 里的说明) */
|
|
553
|
+
bar = document.createElement('span');
|
|
554
|
+
bar.className = 'upload__progress';
|
|
555
|
+
var rmBtn = li.querySelector('.upload__remove');
|
|
556
|
+
li.insertBefore(bar, rmBtn || null);
|
|
557
|
+
}
|
|
558
|
+
this.paintProgress(bar, pct);
|
|
559
|
+
/* 进度也写成文本:**信息不能只在图形里**,否则强制色/色盲下丢失 */
|
|
560
|
+
if (meta) meta.textContent = '上传中 ' + Math.round(pct) + '%';
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
/* ================================================================ 移除 */
|
|
564
|
+
|
|
565
|
+
removeAt: function (i) {
|
|
566
|
+
var files = currentFiles(this.input);
|
|
567
|
+
if (i < 0 || i >= files.length) return false;
|
|
568
|
+
|
|
569
|
+
var removed = files[i];
|
|
570
|
+
var rest = files.slice(0, i).concat(files.slice(i + 1));
|
|
571
|
+
if (!writeFiles(this.input, rest)) return false; /* 不可写 ⇒ 不假成功 */
|
|
572
|
+
|
|
573
|
+
this.progress.splice(i, 1);
|
|
574
|
+
|
|
575
|
+
/* 🔴 只删这一个节点、不整表重建:重建会把焦点从"移除"按钮上打掉,
|
|
576
|
+
键盘用户连按删除时每删一个就丢一次焦点,得重新 Tab 回来。 */
|
|
577
|
+
var li = this.list.querySelectorAll('.upload__item')[i];
|
|
578
|
+
if (li && li.parentNode) li.parentNode.removeChild(li);
|
|
579
|
+
|
|
580
|
+
this.rebind(); /* 索引整体前移,旧闭包里的 i 已过期 */
|
|
581
|
+
this.syncCount();
|
|
582
|
+
this.announce('已移除 ' + (removed.name || '文件') + ',还剩 ' + rest.length + ' 个');
|
|
583
|
+
flEmit(this.root, 'fl-upload', {
|
|
584
|
+
files: rest, added: 0, removed: removed, rejected: []
|
|
585
|
+
});
|
|
586
|
+
return true;
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
rebind: function () {
|
|
590
|
+
/* 移除后索引整体前移 ⇒ 必须重绑,否则"删掉第 1 个之后再点第 2 个"
|
|
591
|
+
会去删原来第 3 个。这是列表组件最经典的 off-by-one。
|
|
592
|
+
用 cloneNode 换掉旧节点:addEventListener 无法移除匿名监听。 */
|
|
593
|
+
var self = this;
|
|
594
|
+
var items = this.list.querySelectorAll('.upload__item');
|
|
595
|
+
for (var i = 0; i < items.length; i++) {
|
|
596
|
+
var btn = items[i].querySelector('.upload__remove');
|
|
597
|
+
if (!btn) continue;
|
|
598
|
+
var fresh = btn.cloneNode(true);
|
|
599
|
+
btn.parentNode.replaceChild(fresh, btn);
|
|
600
|
+
(function (idx) {
|
|
601
|
+
fresh.addEventListener('click', function () { self.removeAt(idx); });
|
|
602
|
+
})(i);
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
/* ================================================================ 播报 */
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* `role="status"` 播报。
|
|
610
|
+
* ⚠️ 同一句话连续播两次时读屏**不会**再读(内容没变)⇒ 先清空、隔一个 tick 再写。
|
|
611
|
+
* 否则"移除最后一个文件"这类重复操作第二次是静默的。
|
|
612
|
+
*/
|
|
613
|
+
announce: function (msg) {
|
|
614
|
+
if (!this.status) return;
|
|
615
|
+
var el = this.status;
|
|
616
|
+
if (this._announceTimer) global.clearTimeout(this._announceTimer);
|
|
617
|
+
el.textContent = '';
|
|
618
|
+
this._announceTimer = global.setTimeout(function () { el.textContent = msg; }, 60);
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
summary: function (added, rejected) {
|
|
622
|
+
var total = currentFiles(this.input).length;
|
|
623
|
+
var s = '已添加 ' + added + ' 个文件,共 ' + total + ' 个';
|
|
624
|
+
if (rejected) s += ';' + rejected + ' 个未通过校验';
|
|
625
|
+
return s;
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
/* ================================================================ 销毁 */
|
|
629
|
+
|
|
630
|
+
destroy: function () {
|
|
631
|
+
this.input.removeEventListener('change', this._onChange);
|
|
632
|
+
if (this.dropzone) {
|
|
633
|
+
this.dropzone.removeEventListener('dragover', this._onDragOver);
|
|
634
|
+
this.dropzone.removeEventListener('dragenter', this._onDragEnter);
|
|
635
|
+
this.dropzone.removeEventListener('dragleave', this._onDragLeave);
|
|
636
|
+
this.dropzone.removeEventListener('drop', this._onDrop);
|
|
637
|
+
}
|
|
638
|
+
if (this._announceTimer) global.clearTimeout(this._announceTimer);
|
|
639
|
+
if (this.list) this.list.innerHTML = '';
|
|
640
|
+
this.root.removeAttribute('data-count');
|
|
641
|
+
this.root.removeAttribute('data-state');
|
|
642
|
+
if (this.root.uploadInstance === this) this.root.uploadInstance = null;
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
/* ---------------------------------------------------------------- 导出 */
|
|
647
|
+
|
|
648
|
+
global.Upload = Upload; /* construct 形态:new Upload(root, opts) */
|
|
649
|
+
|
|
650
|
+
function init() {
|
|
651
|
+
var nodes = document.querySelectorAll('[data-upload]');
|
|
652
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
653
|
+
/* 🔴 幂等:SPA 路由切回、HMR、重复引入都会二次执行 init。
|
|
654
|
+
不加守卫的后果是静默的 —— 两个实例各挂一个 change 监听,
|
|
655
|
+
一次选择把列表渲染两遍(第二遍把第一遍的进度覆盖成空)。 */
|
|
656
|
+
if (nodes[i].uploadInstance) continue;
|
|
657
|
+
try {
|
|
658
|
+
nodes[i].uploadInstance = new Upload(nodes[i]);
|
|
659
|
+
} catch (e) {
|
|
660
|
+
/* 缺 .upload__input 是**作者错误**,但一个坏标记不该拖垮整页其余控件 */
|
|
661
|
+
if (global.console && global.console.warn) {
|
|
662
|
+
global.console.warn('upload: 初始化失败,已跳过该元素 —— ' + (e && e.message));
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// @autoinit: [data-upload] —— 自动初始化;与 API.md「自动初始化」表由 autoinit-gate 双向校验
|
|
669
|
+
// ⚠️ 无 DOM 环境(SSR / 构建期 / Node 里 import)不自动初始化。
|
|
670
|
+
if (typeof document === 'undefined') return;
|
|
671
|
+
if (document.readyState === 'loading') {
|
|
672
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
673
|
+
} else {
|
|
674
|
+
init();
|
|
675
|
+
}
|
|
676
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|