@icen.ai/ui 0.3.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.
- package/dist/behaviors/back-top.mjs +84 -0
- package/dist/behaviors/charts.mjs +188 -0
- package/dist/behaviors/command-palette.mjs +177 -0
- package/dist/behaviors/datatable.mjs +244 -24
- package/dist/behaviors/date-picker.mjs +228 -0
- package/dist/behaviors/input.mjs +170 -1
- package/dist/behaviors/notification.mjs +754 -0
- package/dist/behaviors/select.mjs +151 -22
- package/dist/behaviors/split-pane.mjs +102 -0
- package/dist/behaviors/upload.mjs +165 -14
- package/dist/components/back-top.css +88 -0
- package/dist/components/back-top.mjs +3 -0
- package/dist/components/badge.css +131 -0
- package/dist/components/badge.mjs +2 -0
- package/dist/components/breadcrumb.css +57 -0
- package/dist/components/card.css +68 -0
- package/dist/components/chart-area.css +15 -0
- package/dist/components/chart-area.mjs +4 -0
- package/dist/components/chart-bar.css +79 -0
- package/dist/components/chart-bar.mjs +4 -0
- package/dist/components/chart-gauge.css +38 -0
- package/dist/components/chart-gauge.mjs +4 -0
- package/dist/components/chart-heatmap.css +52 -0
- package/dist/components/chart-heatmap.mjs +4 -0
- package/dist/components/chart-line.css +15 -0
- package/dist/components/chart-line.mjs +4 -0
- package/dist/components/chart-pie.css +26 -0
- package/dist/components/chart-pie.mjs +4 -0
- package/dist/components/chart-radar.css +19 -0
- package/dist/components/chart-radar.mjs +4 -0
- package/dist/components/chart-sparkline.css +14 -0
- package/dist/components/chart-sparkline.mjs +4 -0
- package/dist/components/chart-stack.css +30 -0
- package/dist/components/chart-stack.mjs +4 -0
- package/dist/components/charts.css +7 -207
- package/dist/components/charts.mjs +9 -0
- package/dist/components/command-palette.css +246 -0
- package/dist/components/command-palette.mjs +3 -0
- package/dist/components/content.css +214 -0
- package/dist/components/copy.css +93 -0
- package/dist/components/copy.mjs +3 -0
- package/dist/components/datatable.css +86 -0
- package/dist/components/date-picker.css +244 -0
- package/dist/components/date-picker.mjs +3 -0
- package/dist/components/empty.css +73 -3
- package/dist/components/feedback.css +239 -1
- package/dist/components/form.css +119 -0
- package/dist/components/form.mjs +2 -0
- package/dist/components/input.css +24 -1
- package/dist/components/media.css +144 -1
- package/dist/components/nav.css +99 -0
- package/dist/components/notification.css +387 -0
- package/dist/components/notification.mjs +3 -0
- package/dist/components/panel.css +100 -5
- package/dist/components/pill.css +70 -0
- package/dist/components/scroll-area.css +112 -0
- package/dist/components/scroll-area.mjs +2 -0
- package/dist/components/segmented.css +75 -0
- package/dist/components/select.css +97 -2
- package/dist/components/sidebar.css +118 -0
- package/dist/components/slider.css +113 -0
- package/dist/components/split-pane.css +112 -0
- package/dist/components/split-pane.mjs +3 -0
- package/dist/components/stat.css +129 -6
- package/dist/components/steps.css +97 -0
- package/dist/components/switch.css +142 -0
- package/dist/components/table.css +86 -3
- package/dist/components/tabs.css +122 -1
- package/dist/components/tag.css +164 -0
- package/dist/components/tag.mjs +2 -0
- package/dist/components/toast.css +114 -0
- package/dist/components/toolbar.css +101 -0
- package/dist/components/toolbar.mjs +2 -0
- package/dist/components/upload.css +61 -4
- package/dist/index.mjs +2039 -62
- package/dist/registry.json +200 -24
- package/dist/ui.css +4865 -622
- 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
|
|
14
|
-
const
|
|
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 =
|
|
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
|
|
41
|
+
return visibleOptions().findIndex((o) => !o.classList.contains("is-disabled"));
|
|
25
42
|
}
|
|
26
43
|
function moveFocus(delta) {
|
|
27
|
-
const opts =
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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 =
|
|
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 =
|
|
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", () =>
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
}
|