@icen.ai/ui 0.4.0 → 0.5.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.
Files changed (55) hide show
  1. package/dist/behaviors/back-top.mjs +84 -0
  2. package/dist/behaviors/command-palette.mjs +177 -0
  3. package/dist/behaviors/date-picker.mjs +228 -0
  4. package/dist/behaviors/input.mjs +170 -1
  5. package/dist/behaviors/notification.mjs +754 -0
  6. package/dist/behaviors/select.mjs +151 -22
  7. package/dist/behaviors/split-pane.mjs +102 -0
  8. package/dist/behaviors/upload.mjs +165 -14
  9. package/dist/components/back-top.css +88 -0
  10. package/dist/components/back-top.mjs +3 -0
  11. package/dist/components/badge.css +131 -0
  12. package/dist/components/badge.mjs +2 -0
  13. package/dist/components/breadcrumb.css +57 -0
  14. package/dist/components/card.css +68 -0
  15. package/dist/components/command-palette.css +246 -0
  16. package/dist/components/command-palette.mjs +3 -0
  17. package/dist/components/content.css +214 -0
  18. package/dist/components/copy.css +93 -0
  19. package/dist/components/copy.mjs +3 -0
  20. package/dist/components/date-picker.css +244 -0
  21. package/dist/components/date-picker.mjs +3 -0
  22. package/dist/components/empty.css +73 -3
  23. package/dist/components/feedback.css +239 -1
  24. package/dist/components/form.css +119 -0
  25. package/dist/components/form.mjs +2 -0
  26. package/dist/components/input.css +24 -1
  27. package/dist/components/media.css +144 -1
  28. package/dist/components/nav.css +99 -0
  29. package/dist/components/notification.css +387 -0
  30. package/dist/components/notification.mjs +3 -0
  31. package/dist/components/panel.css +100 -5
  32. package/dist/components/pill.css +70 -0
  33. package/dist/components/scroll-area.css +112 -0
  34. package/dist/components/scroll-area.mjs +2 -0
  35. package/dist/components/segmented.css +75 -0
  36. package/dist/components/select.css +97 -2
  37. package/dist/components/sidebar.css +118 -0
  38. package/dist/components/slider.css +113 -0
  39. package/dist/components/split-pane.css +112 -0
  40. package/dist/components/split-pane.mjs +3 -0
  41. package/dist/components/stat.css +129 -6
  42. package/dist/components/steps.css +97 -0
  43. package/dist/components/switch.css +142 -0
  44. package/dist/components/table.css +86 -3
  45. package/dist/components/tabs.css +122 -1
  46. package/dist/components/tag.css +164 -0
  47. package/dist/components/tag.mjs +2 -0
  48. package/dist/components/toast.css +114 -0
  49. package/dist/components/toolbar.css +101 -0
  50. package/dist/components/toolbar.mjs +2 -0
  51. package/dist/components/upload.css +61 -4
  52. package/dist/index.mjs +1858 -69
  53. package/dist/registry.json +64 -1
  54. package/dist/ui.css +4320 -278
  55. package/package.json +1 -1
@@ -10,21 +10,38 @@ function setup(container) {
10
10
  if (!triggerEl || !panelEl) return;
11
11
  const trigger = triggerEl;
12
12
  const panel = panelEl;
13
- const options = () => Array.from(panel.querySelectorAll(".select-option"));
14
- const isOpen = () => !panel.hidden;
13
+ const isMultiple = container.hasAttribute("data-select-multiple");
14
+ const isSearchable = container.hasAttribute("data-select-search");
15
+ const maxSelect = Number(container.getAttribute("data-select-max")) || 0;
16
+ const placeholder = container.getAttribute("data-select-placeholder") ?? (valueEl?.classList.contains("is-empty") ? valueEl?.textContent ?? "" : "");
17
+ let searchInput = null;
15
18
  let focusIdx = -1;
19
+ if (isSearchable && !panel.querySelector(".select-search-input")) {
20
+ const searchWrap = document.createElement("div");
21
+ searchWrap.className = "select-search";
22
+ const input = document.createElement("input");
23
+ input.className = "select-search-input";
24
+ input.type = "search";
25
+ input.placeholder = "\u641C\u7D22\u2026";
26
+ searchWrap.appendChild(input);
27
+ panel.insertBefore(searchWrap, panel.firstChild);
28
+ }
29
+ searchInput = panel.querySelector(".select-search-input");
30
+ const allOptions = () => Array.from(panel.querySelectorAll(".select-option"));
31
+ const visibleOptions = () => allOptions().filter((o) => !o.hidden);
32
+ const isOpen = () => !panel.hidden;
16
33
  function setFocus(idx) {
17
- const opts = options();
34
+ const opts = visibleOptions();
18
35
  focusIdx = Math.max(-1, Math.min(idx, opts.length - 1));
19
36
  opts.forEach((o, i) => o.classList.toggle("is-focused", i === focusIdx));
20
37
  const cur = focusIdx >= 0 ? opts[focusIdx] : void 0;
21
38
  cur?.scrollIntoView({ block: "nearest" });
22
39
  }
23
40
  function firstEnabled() {
24
- return options().findIndex((o) => !o.classList.contains("is-disabled"));
41
+ return visibleOptions().findIndex((o) => !o.classList.contains("is-disabled"));
25
42
  }
26
43
  function moveFocus(delta) {
27
- const opts = options();
44
+ const opts = visibleOptions();
28
45
  if (opts.length === 0) return;
29
46
  let n = focusIdx;
30
47
  for (let guard = 0; guard < opts.length; guard++) {
@@ -34,13 +51,57 @@ function setup(container) {
34
51
  }
35
52
  setFocus(n);
36
53
  }
54
+ let emptyEl = panel.querySelector(".select-empty");
55
+ if (!emptyEl) {
56
+ emptyEl = document.createElement("div");
57
+ emptyEl.className = "select-empty";
58
+ emptyEl.textContent = "\u65E0\u5339\u914D\u9879";
59
+ emptyEl.hidden = true;
60
+ panel.appendChild(emptyEl);
61
+ }
62
+ function updateEmptyState() {
63
+ const hasVisible = visibleOptions().length > 0;
64
+ if (emptyEl) emptyEl.hidden = hasVisible;
65
+ }
66
+ function filterOptions(query) {
67
+ const q = query.trim().toLowerCase();
68
+ const groups = Array.from(panel.querySelectorAll(".select-group"));
69
+ for (const opt of allOptions()) {
70
+ const text = (opt.textContent ?? "").toLowerCase();
71
+ const kw = (opt.getAttribute("data-keywords") ?? "").toLowerCase();
72
+ opt.hidden = q !== "" && !text.includes(q) && !kw.includes(q);
73
+ }
74
+ for (const g of groups) {
75
+ const hasVisible = Array.from(g.querySelectorAll(".select-option")).some((o) => !o.hidden);
76
+ g.hidden = !hasVisible;
77
+ }
78
+ updateEmptyState();
79
+ setFocus(firstEnabled());
80
+ }
81
+ if (searchInput) {
82
+ searchInput.addEventListener("input", () => filterOptions(searchInput.value));
83
+ searchInput.addEventListener("keydown", (ev) => {
84
+ if (ev.key === "Escape") {
85
+ ev.stopPropagation();
86
+ searchInput.value = "";
87
+ filterOptions("");
88
+ searchInput.focus();
89
+ }
90
+ });
91
+ }
37
92
  function openPanel() {
38
93
  if (isOpen()) return;
39
94
  panel.hidden = false;
40
95
  container.classList.add("is-open");
41
96
  trigger.setAttribute("aria-expanded", "true");
42
- const sel = options().findIndex((o) => o.classList.contains("is-selected"));
43
- setFocus(sel >= 0 ? sel : firstEnabled());
97
+ if (searchInput) {
98
+ searchInput.value = "";
99
+ filterOptions("");
100
+ setTimeout(() => searchInput?.focus(), 0);
101
+ } else {
102
+ const sel = allOptions().findIndex((o) => o.classList.contains("is-selected"));
103
+ setFocus(sel >= 0 ? sel : firstEnabled());
104
+ }
44
105
  }
45
106
  function closePanel() {
46
107
  if (!isOpen()) return;
@@ -49,21 +110,74 @@ function setup(container) {
49
110
  trigger.setAttribute("aria-expanded", "false");
50
111
  setFocus(-1);
51
112
  }
52
- function choose(opt) {
53
- if (opt.classList.contains("is-disabled")) return;
54
- options().forEach((o) => o.classList.toggle("is-selected", o === opt));
55
- if (valueEl) {
56
- valueEl.textContent = opt.textContent ?? "";
113
+ function getSelectedValues() {
114
+ return allOptions().filter((o) => o.classList.contains("is-selected")).map((o) => o.getAttribute("data-value") ?? "");
115
+ }
116
+ function updateMultiTrigger() {
117
+ if (!valueEl) return;
118
+ const selected = allOptions().filter((o) => o.classList.contains("is-selected"));
119
+ if (selected.length === 0) {
120
+ valueEl.textContent = placeholder;
121
+ valueEl.classList.add("is-empty");
122
+ container.removeAttribute("data-value");
123
+ } else {
57
124
  valueEl.classList.remove("is-empty");
125
+ const maxLabel = Number(container.getAttribute("data-select-max-label")) || 2;
126
+ if (selected.length <= maxLabel) {
127
+ valueEl.textContent = selected.map((o) => o.textContent ?? "").join("\u3001");
128
+ } else {
129
+ valueEl.textContent = `\u5DF2\u9009 ${selected.length} \u9879`;
130
+ }
58
131
  }
59
- const v = opt.getAttribute("data-value") ?? "";
60
- container.setAttribute("data-value", v);
61
- if (hiddenInput) {
62
- hiddenInput.value = v;
63
- hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
132
+ if (maxSelect > 0) {
133
+ const atMax = selected.length >= maxSelect;
134
+ allOptions().forEach((o) => {
135
+ if (!o.classList.contains("is-selected")) {
136
+ o.classList.toggle("is-disabled", atMax);
137
+ }
138
+ });
64
139
  }
65
- closePanel();
66
- trigger.focus();
140
+ }
141
+ function syncHiddenInput() {
142
+ if (!hiddenInput) return;
143
+ const values = getSelectedValues();
144
+ hiddenInput.value = isMultiple ? values.join(",") : values[0] ?? "";
145
+ hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
146
+ }
147
+ function choose(opt) {
148
+ if (opt.classList.contains("is-disabled")) return;
149
+ if (isMultiple) {
150
+ opt.classList.toggle("is-selected");
151
+ container.setAttribute(
152
+ "data-value",
153
+ getSelectedValues().join(",")
154
+ );
155
+ updateMultiTrigger();
156
+ syncHiddenInput();
157
+ if (searchInput) searchInput.focus();
158
+ else opt.focus();
159
+ } else {
160
+ allOptions().forEach((o) => o.classList.toggle("is-selected", o === opt));
161
+ if (valueEl) {
162
+ valueEl.textContent = opt.textContent ?? "";
163
+ valueEl.classList.remove("is-empty");
164
+ }
165
+ const v = opt.getAttribute("data-value") ?? "";
166
+ container.setAttribute("data-value", v);
167
+ syncHiddenInput();
168
+ closePanel();
169
+ trigger.focus();
170
+ }
171
+ }
172
+ const clearBtn = container.querySelector(".select-clear");
173
+ if (clearBtn && isMultiple) {
174
+ clearBtn.addEventListener("click", (ev) => {
175
+ ev.stopPropagation();
176
+ allOptions().forEach((o) => o.classList.remove("is-selected"));
177
+ updateMultiTrigger();
178
+ syncHiddenInput();
179
+ });
180
+ clearBtn.addEventListener("pointerdown", (ev) => ev.stopPropagation());
67
181
  }
68
182
  trigger.addEventListener("click", () => isOpen() ? closePanel() : openPanel());
69
183
  trigger.addEventListener("keydown", (ev) => {
@@ -74,6 +188,20 @@ function setup(container) {
74
188
  }
75
189
  return;
76
190
  }
191
+ if (searchInput && document.activeElement === searchInput) {
192
+ if (ev.key === "ArrowDown") {
193
+ ev.preventDefault();
194
+ moveFocus(1);
195
+ } else if (ev.key === "ArrowUp") {
196
+ ev.preventDefault();
197
+ moveFocus(-1);
198
+ } else if (ev.key === "Enter") {
199
+ ev.preventDefault();
200
+ const opt = visibleOptions()[focusIdx];
201
+ if (opt) choose(opt);
202
+ }
203
+ return;
204
+ }
77
205
  switch (ev.key) {
78
206
  case "ArrowDown":
79
207
  ev.preventDefault();
@@ -86,7 +214,7 @@ function setup(container) {
86
214
  case "Enter":
87
215
  case " ": {
88
216
  ev.preventDefault();
89
- const opt = options()[focusIdx];
217
+ const opt = visibleOptions()[focusIdx];
90
218
  if (opt) choose(opt);
91
219
  break;
92
220
  }
@@ -106,8 +234,8 @@ function setup(container) {
106
234
  const t = ev.target;
107
235
  if (!(t instanceof Element)) return;
108
236
  const opt = t.closest(".select-option");
109
- if (!opt) return;
110
- const idx = options().indexOf(opt);
237
+ if (!opt || opt.hidden) return;
238
+ const idx = visibleOptions().indexOf(opt);
111
239
  if (idx >= 0 && !opt.classList.contains("is-disabled")) setFocus(idx);
112
240
  });
113
241
  document.addEventListener("pointerdown", (ev) => {
@@ -119,6 +247,7 @@ function setup(container) {
119
247
  trigger.focus();
120
248
  }
121
249
  });
250
+ if (isMultiple) updateMultiTrigger();
122
251
  }
123
252
  function initSelect(root = document) {
124
253
  if (typeof document === "undefined") return;
@@ -0,0 +1,102 @@
1
+ // src/behaviors/split-pane.ts
2
+ var initialized = /* @__PURE__ */ new WeakSet();
3
+ function pct(value, min, max) {
4
+ return Math.min(max, Math.max(min, value));
5
+ }
6
+ function setup(container) {
7
+ const el = container;
8
+ if (el.__icenSplitPaneInit) return;
9
+ el.__icenSplitPaneInit = true;
10
+ const dividerQuery = el.querySelector(".split-pane-divider");
11
+ if (!dividerQuery) return;
12
+ const divider = dividerQuery;
13
+ const min = Number(el.dataset.splitMin ?? "10") || 10;
14
+ const max = Number(el.dataset.splitMax ?? "90") || 90;
15
+ const step = Number(el.dataset.splitStep ?? "2") || 2;
16
+ const vertical = el.classList.contains("split-pane--vertical");
17
+ const orientation = vertical ? "vertical" : "horizontal";
18
+ divider.setAttribute("role", "separator");
19
+ divider.setAttribute("aria-orientation", orientation === "vertical" ? "horizontal" : "vertical");
20
+ divider.setAttribute("aria-valuemin", String(min));
21
+ divider.setAttribute("aria-valuemax", String(max));
22
+ divider.tabIndex = 0;
23
+ function readCurrent() {
24
+ const v = parseFloat(getComputedStyle(el).getPropertyValue("--split"));
25
+ return Number.isFinite(v) ? v : 50;
26
+ }
27
+ function write(p) {
28
+ const v = pct(p, min, max);
29
+ el.style.setProperty("--split", v + "%");
30
+ divider.setAttribute("aria-valuenow", String(Math.round(v)));
31
+ }
32
+ if (!el.style.getPropertyValue("--split")) write(50);
33
+ divider.setAttribute("aria-valuenow", String(Math.round(readCurrent())));
34
+ let dragging = false;
35
+ divider.addEventListener("pointerdown", (ev) => {
36
+ dragging = true;
37
+ divider.classList.add("is-dragging");
38
+ divider.setPointerCapture(ev.pointerId);
39
+ el.style.userSelect = "none";
40
+ });
41
+ const onUp = (ev) => {
42
+ if (!dragging) return;
43
+ dragging = false;
44
+ divider.classList.remove("is-dragging");
45
+ try {
46
+ divider.releasePointerCapture(ev.pointerId);
47
+ } catch {
48
+ }
49
+ el.style.userSelect = "";
50
+ };
51
+ divider.addEventListener("pointerup", onUp);
52
+ divider.addEventListener("pointercancel", () => {
53
+ dragging = false;
54
+ divider.classList.remove("is-dragging");
55
+ el.style.userSelect = "";
56
+ });
57
+ divider.addEventListener("pointermove", (ev) => {
58
+ if (!dragging) return;
59
+ const rect = el.getBoundingClientRect();
60
+ if (vertical) {
61
+ if (rect.height <= 0) return;
62
+ const p = (ev.clientY - rect.top) / rect.height * 100;
63
+ write(p);
64
+ } else {
65
+ if (rect.width <= 0) return;
66
+ const p = (ev.clientX - rect.left) / rect.width * 100;
67
+ write(p);
68
+ }
69
+ });
70
+ divider.addEventListener("dblclick", () => write(50));
71
+ divider.addEventListener("keydown", (ev) => {
72
+ const current = readCurrent();
73
+ let next = null;
74
+ if (vertical) {
75
+ if (ev.key === "ArrowUp") next = current - step;
76
+ else if (ev.key === "ArrowDown") next = current + step;
77
+ } else {
78
+ if (ev.key === "ArrowLeft") next = current - step;
79
+ else if (ev.key === "ArrowRight") next = current + step;
80
+ }
81
+ if (ev.key === "Home") next = min;
82
+ else if (ev.key === "End") next = max;
83
+ if (next === null) return;
84
+ ev.preventDefault();
85
+ write(next);
86
+ });
87
+ }
88
+ function initSplitPane(root = document) {
89
+ if (typeof document === "undefined") return;
90
+ const panes = [];
91
+ if (root instanceof Element && root.matches(".split-pane[data-split-pane]")) panes.push(root);
92
+ panes.push(...Array.from(root.querySelectorAll(".split-pane[data-split-pane]")));
93
+ for (const p of panes) {
94
+ const el = p;
95
+ if (initialized.has(el)) continue;
96
+ initialized.add(el);
97
+ setup(el);
98
+ }
99
+ }
100
+ export {
101
+ initSplitPane
102
+ };
@@ -1,13 +1,156 @@
1
1
  // src/behaviors/upload.ts
2
+ function formatSize(bytes) {
3
+ if (bytes < 1024) return `${bytes} B`;
4
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
5
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
6
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
7
+ }
8
+ function isAccepted(file, accept) {
9
+ if (!accept.trim()) return true;
10
+ const types = accept.split(",").map((t) => t.trim().toLowerCase());
11
+ const fileName = file.name.toLowerCase();
12
+ const mime = file.type.toLowerCase();
13
+ return types.some((t) => {
14
+ if (t.startsWith(".")) return fileName.endsWith(t);
15
+ if (t.endsWith("/*")) return mime.startsWith(t.slice(0, -1));
16
+ return mime === t;
17
+ });
18
+ }
19
+ var fileIconSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
20
+ var xIconSvg = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
2
21
  function setup(zone) {
3
22
  const el = zone;
4
23
  if (el.__icenUploadInit) return;
5
24
  el.__icenUploadInit = true;
6
25
  const input = zone.querySelector('input[type="file"]');
7
26
  if (!input) return;
27
+ const hasList = zone.hasAttribute("data-upload-list");
28
+ const maxSize = Number(zone.getAttribute("data-max-size")) || 0;
29
+ const maxFiles = Number(zone.getAttribute("data-max-files")) || 0;
30
+ const accept = input.getAttribute("accept") ?? "";
31
+ let currentFiles = [];
32
+ const urlMap = /* @__PURE__ */ new Map();
33
+ function getListEl() {
34
+ if (!hasList) return null;
35
+ let list = zone.querySelector(".upload-list");
36
+ if (!list) {
37
+ list = document.createElement("ul");
38
+ list.className = "upload-list";
39
+ zone.appendChild(list);
40
+ }
41
+ return list;
42
+ }
43
+ function renderList() {
44
+ const list = getListEl();
45
+ if (!list) return;
46
+ urlMap.forEach((url) => URL.revokeObjectURL(url));
47
+ urlMap.clear();
48
+ list.textContent = "";
49
+ if (currentFiles.length === 0) {
50
+ list.hidden = true;
51
+ return;
52
+ }
53
+ list.hidden = false;
54
+ for (const file of currentFiles) {
55
+ const li = document.createElement("li");
56
+ li.className = "upload-list-item";
57
+ const isImage = file.type.startsWith("image/");
58
+ if (isImage) {
59
+ try {
60
+ const url = URL.createObjectURL(file);
61
+ urlMap.set(file, url);
62
+ const img = document.createElement("img");
63
+ img.className = "upload-list-item-thumb";
64
+ img.src = url;
65
+ img.alt = file.name;
66
+ li.appendChild(img);
67
+ } catch {
68
+ const icon = document.createElement("span");
69
+ icon.className = "upload-list-item-icon";
70
+ icon.innerHTML = fileIconSvg;
71
+ li.appendChild(icon);
72
+ }
73
+ } else {
74
+ const icon = document.createElement("span");
75
+ icon.className = "upload-list-item-icon";
76
+ icon.innerHTML = fileIconSvg;
77
+ li.appendChild(icon);
78
+ }
79
+ const info = document.createElement("div");
80
+ info.className = "upload-list-item-info";
81
+ const name = document.createElement("span");
82
+ name.className = "upload-list-item-name";
83
+ name.textContent = file.name;
84
+ const size = document.createElement("span");
85
+ size.className = "upload-list-item-size";
86
+ size.textContent = formatSize(file.size);
87
+ info.appendChild(name);
88
+ info.appendChild(size);
89
+ li.appendChild(info);
90
+ const removeBtn = document.createElement("button");
91
+ removeBtn.className = "upload-list-item-x";
92
+ removeBtn.type = "button";
93
+ removeBtn.setAttribute("aria-label", `\u79FB\u9664 ${file.name}`);
94
+ removeBtn.innerHTML = xIconSvg;
95
+ removeBtn.addEventListener("click", (ev) => {
96
+ ev.stopPropagation();
97
+ currentFiles = currentFiles.filter((f) => f !== file);
98
+ zone.dispatchEvent(new CustomEvent("icen:upload-remove", { bubbles: true, detail: { file } }));
99
+ renderList();
100
+ syncInputFiles();
101
+ });
102
+ li.appendChild(removeBtn);
103
+ list.appendChild(li);
104
+ }
105
+ }
106
+ function syncInputFiles() {
107
+ try {
108
+ const dt = new DataTransfer();
109
+ for (const f of currentFiles) dt.items.add(f);
110
+ input.files = dt.files;
111
+ input.dispatchEvent(new Event("change", { bubbles: true }));
112
+ } catch {
113
+ }
114
+ }
115
+ function validate(file) {
116
+ if (accept && !isAccepted(file, accept)) return "\u6587\u4EF6\u7C7B\u578B\u4E0D\u652F\u6301";
117
+ if (maxSize > 0 && file.size > maxSize) return `\u6587\u4EF6\u8D85\u8FC7 ${formatSize(maxSize)}`;
118
+ return null;
119
+ }
120
+ function handleFiles(files) {
121
+ const valid = [];
122
+ for (const file of files) {
123
+ const reason = validate(file);
124
+ if (reason) {
125
+ zone.dispatchEvent(new CustomEvent("icen:upload-error", { bubbles: true, detail: { file, reason } }));
126
+ continue;
127
+ }
128
+ valid.push(file);
129
+ }
130
+ if (valid.length === 0) return;
131
+ if (hasList) {
132
+ if (maxFiles > 0) {
133
+ const remaining = maxFiles - currentFiles.length;
134
+ if (remaining <= 0) {
135
+ zone.dispatchEvent(new CustomEvent("icen:upload-error", {
136
+ bubbles: true,
137
+ detail: { reason: `\u6700\u591A ${maxFiles} \u4E2A\u6587\u4EF6` }
138
+ }));
139
+ return;
140
+ }
141
+ valid.splice(remaining);
142
+ }
143
+ currentFiles.push(...valid);
144
+ renderList();
145
+ syncInputFiles();
146
+ }
147
+ zone.dispatchEvent(new CustomEvent("icen:upload", { bubbles: true, detail: { files: valid } }));
148
+ }
8
149
  const openPicker = () => input.click();
9
150
  zone.addEventListener("click", (ev) => {
10
151
  if (ev.target === input) return;
152
+ const tgt = ev.target;
153
+ if (tgt instanceof Element && tgt.closest(".upload-list")) return;
11
154
  openPicker();
12
155
  });
13
156
  zone.addEventListener("keydown", (ev) => {
@@ -16,30 +159,38 @@ function setup(zone) {
16
159
  openPicker();
17
160
  }
18
161
  });
162
+ input.addEventListener("change", () => {
163
+ if (!input.files) return;
164
+ const files = Array.from(input.files);
165
+ if (hasList) {
166
+ handleFiles(files);
167
+ input.value = "";
168
+ } else {
169
+ handleFiles(files);
170
+ }
171
+ });
19
172
  zone.addEventListener("dragover", (ev) => {
20
173
  ev.preventDefault();
21
174
  zone.classList.add("is-drag");
22
175
  });
23
- zone.addEventListener("dragleave", () => zone.classList.remove("is-drag"));
176
+ zone.addEventListener("dragleave", (ev) => {
177
+ if (ev.target === zone) zone.classList.remove("is-drag");
178
+ });
24
179
  zone.addEventListener("drop", (ev) => {
25
180
  ev.preventDefault();
26
181
  zone.classList.remove("is-drag");
27
182
  const files = ev.dataTransfer?.files;
28
183
  if (!files || files.length === 0) return;
29
- let assigned = false;
30
- try {
31
- const dt = new DataTransfer();
32
- for (const f of Array.from(files)) dt.items.add(f);
33
- input.files = dt.files;
34
- assigned = true;
35
- } catch {
36
- assigned = false;
37
- }
38
- if (assigned) {
39
- input.dispatchEvent(new Event("change", { bubbles: true }));
184
+ handleFiles(Array.from(files));
185
+ if (!hasList) {
186
+ try {
187
+ const dt = new DataTransfer();
188
+ for (const f of Array.from(files)) dt.items.add(f);
189
+ input.files = dt.files;
190
+ input.dispatchEvent(new Event("change", { bubbles: true }));
191
+ } catch {
192
+ }
40
193
  }
41
- const list = Array.from(files);
42
- zone.dispatchEvent(new CustomEvent("icen:upload", { bubbles: true, detail: { files: list } }));
43
194
  });
44
195
  }
45
196
  function initUpload(root = document) {
@@ -0,0 +1,88 @@
1
+ /*
2
+ * @icen.ai/ui — Component: back-top(回到顶部按钮,与 behaviors/back-top 配套)
3
+ *
4
+ * 契约(两种用法):
5
+ * 1. 手写 + init:behavior 给 .back-top[data-back-top] 加 .is-shown 类
6
+ * <button class="back-top" data-back-top type="button" aria-label="回到顶部">…svg…</button>
7
+ *
8
+ * 2. 全自动:initBackTop() 后任何 [data-back-top-auto] 会被自动 portal 到 body
9
+ * 并接管滚到顶部逻辑,无需手写按钮
10
+ * initBackTop({ autoCreate: true }) // 自动创建一个默认样式的按钮
11
+ *
12
+ * 默认滚动超过 320px 后出现;点击后 window.scrollTo({ top: 0, behavior: 'smooth' })。
13
+ */
14
+
15
+ .back-top {
16
+ position: fixed;
17
+ right: var(--density-space-lg);
18
+ bottom: var(--density-space-lg);
19
+ z-index: var(--z-sticky);
20
+ display: inline-flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ width: 40px;
24
+ height: 40px;
25
+ padding: 0;
26
+ background: var(--token-card-strong);
27
+ color: var(--token-text-muted);
28
+ border: 1px solid var(--token-outline-soft);
29
+ border-radius: var(--radius-full);
30
+ box-shadow: var(--token-shadow-overlay);
31
+ cursor: pointer;
32
+ -webkit-tap-highlight-color: transparent;
33
+ opacity: 0;
34
+ transform: translateY(12px) scale(0.9);
35
+ pointer-events: none;
36
+ transition:
37
+ opacity var(--icen-tx-normal),
38
+ transform var(--icen-tx-normal),
39
+ color var(--icen-tx-fast),
40
+ border-color var(--icen-tx-fast);
41
+ }
42
+ .back-top.is-shown {
43
+ opacity: 1;
44
+ transform: translateY(0) scale(1);
45
+ pointer-events: auto;
46
+ }
47
+ .back-top:hover {
48
+ color: var(--token-accent);
49
+ border-color: var(--token-accent);
50
+ }
51
+ .back-top:focus-visible {
52
+ outline: none;
53
+ box-shadow: var(--token-shadow-overlay), var(--icen-ring);
54
+ }
55
+ .back-top svg {
56
+ width: 18px;
57
+ height: 18px;
58
+ flex-shrink: 0;
59
+ }
60
+
61
+ /* 尺寸变体 */
62
+ .back-top--sm {
63
+ width: 32px;
64
+ height: 32px;
65
+ }
66
+ .back-top--sm svg { width: 14px; height: 14px; }
67
+ .back-top--lg {
68
+ width: 48px;
69
+ height: 48px;
70
+ }
71
+ .back-top--lg svg { width: 22px; height: 22px; }
72
+
73
+ /* 方向变体:箭头方向(默认上) */
74
+ .back-top[data-dir='left'] svg { transform: rotate(90deg); }
75
+ .back-top[data-dir='right'] svg { transform: rotate(-90deg); }
76
+ .back-top[data-dir='down'] svg { transform: rotate(180deg); }
77
+
78
+ @media (max-width: 480px) {
79
+ .back-top {
80
+ right: var(--density-space-sm);
81
+ bottom: var(--density-space-sm);
82
+ }
83
+ }
84
+
85
+ @media (prefers-reduced-motion: reduce) {
86
+ .back-top { transition: opacity var(--icen-tx-fast); transform: none !important; }
87
+ .back-top.is-shown { transform: none !important; }
88
+ }
@@ -0,0 +1,3 @@
1
+ // @icen.ai/ui — kit: back-top(CSS + behavior 一行入口;构建产物,勿手改)
2
+ import './back-top.css';
3
+ export * from '../behaviors/back-top.mjs';