@icen.ai/ui 0.4.0 → 0.8.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 (155) hide show
  1. package/AGENTS.md +256 -0
  2. package/README.md +47 -13
  3. package/dist/base.css +31 -4
  4. package/dist/behaviors/accordion.d.mts +4 -0
  5. package/dist/behaviors/accordion.mjs +4 -3
  6. package/dist/behaviors/ai-chat.d.mts +57 -0
  7. package/dist/behaviors/ai-chat.mjs +557 -0
  8. package/dist/behaviors/ai-composer.d.mts +112 -0
  9. package/dist/behaviors/ai-composer.mjs +2032 -0
  10. package/dist/behaviors/ai-core.d.mts +151 -0
  11. package/dist/behaviors/ai-core.mjs +491 -0
  12. package/dist/behaviors/ai-diff.d.mts +53 -0
  13. package/dist/behaviors/ai-diff.mjs +282 -0
  14. package/dist/behaviors/ai-panel.d.mts +98 -0
  15. package/dist/behaviors/ai-panel.mjs +1012 -0
  16. package/dist/behaviors/ai-provider.d.mts +198 -0
  17. package/dist/behaviors/ai-provider.mjs +1055 -0
  18. package/dist/behaviors/ai-tool.d.mts +18 -0
  19. package/dist/behaviors/ai-tool.mjs +2125 -0
  20. package/dist/behaviors/ai-tools.d.mts +81 -0
  21. package/dist/behaviors/ai-tools.mjs +1658 -0
  22. package/dist/behaviors/back-top.d.mts +20 -0
  23. package/dist/behaviors/back-top.mjs +101 -0
  24. package/dist/behaviors/carousel.d.mts +4 -0
  25. package/dist/behaviors/carousel.mjs +34 -16
  26. package/dist/behaviors/charts.d.mts +250 -0
  27. package/dist/behaviors/charts.mjs +1301 -448
  28. package/dist/behaviors/command-palette.d.mts +8 -0
  29. package/dist/behaviors/command-palette.mjs +189 -0
  30. package/dist/behaviors/context-menu.d.mts +36 -0
  31. package/dist/behaviors/context-menu.mjs +6 -2
  32. package/dist/behaviors/copy.d.mts +4 -0
  33. package/dist/behaviors/copy.mjs +7 -6
  34. package/dist/behaviors/datatable.d.mts +78 -0
  35. package/dist/behaviors/datatable.mjs +54 -11
  36. package/dist/behaviors/date-picker.d.mts +4 -0
  37. package/dist/behaviors/date-picker.mjs +296 -0
  38. package/dist/behaviors/dropdown.d.mts +4 -0
  39. package/dist/behaviors/dropdown.mjs +7 -3
  40. package/dist/behaviors/input.d.mts +4 -0
  41. package/dist/behaviors/input.mjs +184 -8
  42. package/dist/behaviors/modal.d.mts +11 -0
  43. package/dist/behaviors/modal.mjs +11 -6
  44. package/dist/behaviors/nav.d.mts +7 -0
  45. package/dist/behaviors/nav.mjs +25 -9
  46. package/dist/behaviors/notification.d.mts +172 -0
  47. package/dist/behaviors/notification.mjs +908 -0
  48. package/dist/behaviors/popover.d.mts +46 -0
  49. package/dist/behaviors/popover.mjs +2 -1
  50. package/dist/behaviors/select.d.mts +4 -0
  51. package/dist/behaviors/select.mjs +268 -34
  52. package/dist/behaviors/sidebar.d.mts +4 -0
  53. package/dist/behaviors/sidebar.mjs +4 -3
  54. package/dist/behaviors/slider.d.mts +4 -0
  55. package/dist/behaviors/slider.mjs +4 -3
  56. package/dist/behaviors/split-pane.d.mts +4 -0
  57. package/dist/behaviors/split-pane.mjs +102 -0
  58. package/dist/behaviors/tabs.d.mts +8 -0
  59. package/dist/behaviors/tabs.mjs +44 -19
  60. package/dist/behaviors/tag-input.d.mts +4 -0
  61. package/dist/behaviors/tag-input.mjs +12 -3
  62. package/dist/behaviors/theme.d.mts +25 -0
  63. package/dist/behaviors/theme.mjs +3 -1
  64. package/dist/behaviors/toast.d.mts +26 -0
  65. package/dist/behaviors/toast.mjs +24 -18
  66. package/dist/behaviors/tree.d.mts +4 -0
  67. package/dist/behaviors/tree.mjs +4 -3
  68. package/dist/behaviors/upload.d.mts +4 -0
  69. package/dist/behaviors/upload.mjs +205 -17
  70. package/dist/components/ai-chat.css +1037 -0
  71. package/dist/components/ai-chat.mjs +3 -0
  72. package/dist/components/ai-composer.mjs +3 -0
  73. package/dist/components/ai-context.mjs +2 -0
  74. package/dist/components/ai-diff.css +311 -0
  75. package/dist/components/ai-diff.mjs +3 -0
  76. package/dist/components/ai-files.mjs +3 -0
  77. package/dist/components/ai-message.mjs +3 -0
  78. package/dist/components/ai-panel.css +682 -0
  79. package/dist/components/ai-reasoning.mjs +3 -0
  80. package/dist/components/ai-subagent.mjs +2 -0
  81. package/dist/components/ai-todo.mjs +2 -0
  82. package/dist/components/ai-tool-call.mjs +2 -0
  83. package/dist/components/ai-tool.css +532 -0
  84. package/dist/components/ai-usage.mjs +3 -0
  85. package/dist/components/back-top.css +88 -0
  86. package/dist/components/back-top.mjs +3 -0
  87. package/dist/components/badge.css +127 -0
  88. package/dist/components/badge.mjs +2 -0
  89. package/dist/components/breadcrumb.css +53 -0
  90. package/dist/components/btn.css +4 -7
  91. package/dist/components/card.css +66 -1
  92. package/dist/components/chart-bar.css +10 -0
  93. package/dist/components/chart-calendar.mjs +5 -0
  94. package/dist/components/chart-gauge.css +1 -1
  95. package/dist/components/chart-heatmap.css +40 -0
  96. package/dist/components/chart-scatter.css +24 -0
  97. package/dist/components/chart-scatter.mjs +4 -0
  98. package/dist/components/charts.css +86 -1
  99. package/dist/components/charts.mjs +1 -0
  100. package/dist/components/command-palette.css +246 -0
  101. package/dist/components/command-palette.mjs +3 -0
  102. package/dist/components/content.css +221 -8
  103. package/dist/components/copy.css +93 -0
  104. package/dist/components/copy.mjs +3 -0
  105. package/dist/components/datatable.css +4 -8
  106. package/dist/components/date-picker.css +240 -0
  107. package/dist/components/date-picker.mjs +3 -0
  108. package/dist/components/empty.css +69 -3
  109. package/dist/components/feedback.css +241 -10
  110. package/dist/components/form.css +129 -11
  111. package/dist/components/form.mjs +2 -0
  112. package/dist/components/input.css +25 -2
  113. package/dist/components/layout.css +36 -36
  114. package/dist/components/media.css +153 -6
  115. package/dist/components/menu.css +2 -2
  116. package/dist/components/modal.css +1 -1
  117. package/dist/components/nav.css +106 -2
  118. package/dist/components/notification.css +387 -0
  119. package/dist/components/notification.mjs +3 -0
  120. package/dist/components/pagination.css +1 -1
  121. package/dist/components/panel.css +104 -9
  122. package/dist/components/pill.css +82 -12
  123. package/dist/components/scroll-area.css +112 -0
  124. package/dist/components/scroll-area.mjs +2 -0
  125. package/dist/components/segmented.css +80 -5
  126. package/dist/components/segmented.mjs +1 -0
  127. package/dist/components/select.css +104 -17
  128. package/dist/components/sidebar.css +119 -1
  129. package/dist/components/slider.css +113 -0
  130. package/dist/components/split-pane.css +111 -0
  131. package/dist/components/split-pane.mjs +3 -0
  132. package/dist/components/stat.css +131 -12
  133. package/dist/components/steps.css +98 -1
  134. package/dist/components/switch.css +145 -7
  135. package/dist/components/table.css +111 -29
  136. package/dist/components/tabs.css +132 -22
  137. package/dist/components/tag-input.css +4 -4
  138. package/dist/components/tag.css +164 -0
  139. package/dist/components/tag.mjs +2 -0
  140. package/dist/components/toast.css +122 -5
  141. package/dist/components/toolbar.css +101 -0
  142. package/dist/components/toolbar.mjs +2 -0
  143. package/dist/components/tooltip.css +2 -2
  144. package/dist/components/upload.css +61 -4
  145. package/dist/index.d.mts +37 -0
  146. package/dist/index.mjs +9409 -929
  147. package/dist/registry.json +176 -4
  148. package/dist/retro-effects.css +21 -17
  149. package/dist/tokens/colors.css +4 -1
  150. package/dist/tokens/retro-effects.css +21 -17
  151. package/dist/tokens/style-profiles.css +21 -0
  152. package/dist/tokens.css +25 -1
  153. package/dist/ui.css +9616 -2839
  154. package/docs/spec/ai-native.md +659 -0
  155. package/package.json +15 -5
@@ -8,28 +8,40 @@ function tabNames(container) {
8
8
  function currentHash() {
9
9
  return window.location.hash.replace(/^#/, "");
10
10
  }
11
- function activate(container, name, writeHash) {
11
+ function activate(container, name, writeHash, emit = false) {
12
12
  const buttons = tabButtons(container);
13
13
  let matched = false;
14
14
  buttons.forEach((t) => {
15
15
  const on = t.getAttribute("data-tab") === name;
16
16
  if (on) matched = true;
17
- t.classList.toggle("active", on);
17
+ t.classList.toggle("is-active", on);
18
18
  t.setAttribute("aria-selected", String(on));
19
19
  t.tabIndex = on ? 0 : -1;
20
20
  });
21
21
  if (!matched) return;
22
+ let panel = null;
22
23
  container.querySelectorAll("[data-tab-panel]").forEach((p) => {
23
24
  const isOn = p.getAttribute("data-tab-panel") === name;
24
25
  p.hidden = !isOn;
25
- if (isOn && !p.getAttribute("aria-labelledby")) {
26
- const btn = buttons.find((b) => b.getAttribute("data-tab") === name);
27
- if (btn?.id) p.setAttribute("aria-labelledby", btn.id);
26
+ if (isOn) {
27
+ panel = p;
28
+ if (!p.getAttribute("aria-labelledby")) {
29
+ const btn = buttons.find((b) => b.getAttribute("data-tab") === name);
30
+ if (btn?.id) p.setAttribute("aria-labelledby", btn.id);
31
+ }
28
32
  }
29
33
  });
30
34
  if (writeHash && typeof window !== "undefined") {
31
35
  window.history.replaceState(null, "", `#${name}`);
32
36
  }
37
+ if (emit) {
38
+ const index = buttons.findIndex((b) => b.getAttribute("data-tab") === name);
39
+ const tab = buttons[index] ?? null;
40
+ container.dispatchEvent(new CustomEvent("icen:tab-change", {
41
+ bubbles: true,
42
+ detail: { tab, panel, index }
43
+ }));
44
+ }
33
45
  }
34
46
  function isDisabled(btn) {
35
47
  return btn.hasAttribute("disabled") || btn.getAttribute("aria-disabled") === "true" || btn.classList.contains("is-disabled");
@@ -52,7 +64,7 @@ function nextEnabled(container, from, dir) {
52
64
  }
53
65
  function setup(container) {
54
66
  const el = container;
55
- if (el.__icenTabsInit) return;
67
+ if (el.__icenTabsInit) return void 0;
56
68
  el.__icenTabsInit = true;
57
69
  const useHash = container.hasAttribute("data-tabs-hash");
58
70
  const names = tabNames(container);
@@ -71,7 +83,7 @@ function setup(container) {
71
83
  if (!btn || btn.closest("[data-tabs]") !== container) return;
72
84
  if (btn instanceof HTMLElement && isDisabled(btn)) return;
73
85
  const name = btn.getAttribute("data-tab");
74
- if (name) activate(container, name, useHash);
86
+ if (name) activate(container, name, useHash, true);
75
87
  };
76
88
  const onKeyDown = (ev) => {
77
89
  const kev = ev;
@@ -99,12 +111,12 @@ function setup(container) {
99
111
  }
100
112
  case "Home": {
101
113
  kev.preventDefault();
102
- focusTab(container, 0);
114
+ focusTab(container, nextEnabled(container, -1, 1));
103
115
  break;
104
116
  }
105
117
  case "End": {
106
118
  kev.preventDefault();
107
- focusTab(container, buttonsList.length - 1);
119
+ focusTab(container, nextEnabled(container, buttonsList.length, -1));
108
120
  break;
109
121
  }
110
122
  case "Enter":
@@ -112,7 +124,7 @@ function setup(container) {
112
124
  if (btn instanceof HTMLElement && !isDisabled(btn)) {
113
125
  kev.preventDefault();
114
126
  const name = btn.getAttribute("data-tab");
115
- if (name) activate(container, name, useHash);
127
+ if (name) activate(container, name, useHash, true);
116
128
  }
117
129
  break;
118
130
  }
@@ -125,29 +137,42 @@ function setup(container) {
125
137
  const h = currentHash();
126
138
  if (names.includes(h)) initial = h;
127
139
  } else {
128
- const n = container.querySelector("[data-tab].active")?.getAttribute("data-tab");
140
+ const n = container.querySelector("[data-tab].is-active")?.getAttribute("data-tab");
129
141
  if (n && names.includes(n)) initial = n;
130
142
  }
131
143
  activate(container, initial ?? names[0], false);
132
144
  const onHash = () => {
133
145
  const h = currentHash();
134
- if (names.includes(h)) activate(container, h, false);
146
+ if (names.includes(h)) activate(container, h, false, true);
135
147
  };
136
148
  if (useHash && typeof window !== "undefined") {
137
149
  window.addEventListener("hashchange", onHash);
138
150
  }
139
- el.__icenTabsCleanup = () => {
151
+ const cleanup = () => {
140
152
  container.removeEventListener("click", onClick);
141
153
  container.removeEventListener("keydown", onKeyDown);
142
154
  window.removeEventListener("hashchange", onHash);
155
+ el.__icenTabsInit = false;
143
156
  };
157
+ el.__icenTabsCleanup = cleanup;
158
+ return cleanup;
144
159
  }
145
- function initTabs(root = document) {
146
- if (typeof document === "undefined") return;
147
- const containers = [];
148
- if (root instanceof Element && root.matches("[data-tabs]")) containers.push(root);
149
- containers.push(...Array.from(root.querySelectorAll("[data-tabs]")));
150
- for (const c of containers) setup(c);
160
+ function initTabs(root) {
161
+ const cleanups = [];
162
+ if (typeof document !== "undefined") {
163
+ const scope = root ?? document;
164
+ const containers = [];
165
+ if (scope instanceof Element && scope.matches("[data-tabs]")) containers.push(scope);
166
+ containers.push(...Array.from(scope.querySelectorAll("[data-tabs]")));
167
+ for (const c of containers) {
168
+ const cleanup = setup(c);
169
+ if (cleanup) cleanups.push(cleanup);
170
+ }
171
+ }
172
+ return () => {
173
+ for (const fn of cleanups) fn();
174
+ cleanups.length = 0;
175
+ };
151
176
  }
152
177
  export {
153
178
  initTabs
@@ -0,0 +1,4 @@
1
+ /** 为 root 下每个 .tag-input 容器初始化(root 自身匹配也算;data-tags 为可选初始值配置)。 */
2
+ declare function initTagInput(root?: ParentNode): void;
3
+
4
+ export { initTagInput };
@@ -9,6 +9,13 @@ function setup(wrap) {
9
9
  const maxAttr = wrap.getAttribute("data-max");
10
10
  const max = maxAttr ? Number(maxAttr) || Infinity : Infinity;
11
11
  const placeholder = field.placeholder;
12
+ let isComposing = false;
13
+ field.addEventListener("compositionstart", () => {
14
+ isComposing = true;
15
+ });
16
+ field.addEventListener("compositionend", () => {
17
+ isComposing = false;
18
+ });
12
19
  let tags = (wrap.getAttribute("data-tags") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
13
20
  function sync() {
14
21
  wrap.setAttribute("data-tags", tags.join(","));
@@ -63,6 +70,7 @@ function setup(wrap) {
63
70
  emit();
64
71
  }
65
72
  field.addEventListener("keydown", (ev) => {
73
+ if (isComposing || ev.isComposing) return;
66
74
  if (ev.key === "Enter" || ev.key === ",") {
67
75
  ev.preventDefault();
68
76
  commit();
@@ -73,11 +81,12 @@ function setup(wrap) {
73
81
  field.addEventListener("blur", commit);
74
82
  render();
75
83
  }
76
- function initTagInput(root = document) {
84
+ function initTagInput(root) {
77
85
  if (typeof document === "undefined") return;
86
+ const scope = root ?? document;
78
87
  const containers = [];
79
- if (root instanceof HTMLElement && root.matches(".tag-input[data-tags]")) containers.push(root);
80
- containers.push(...Array.from(root.querySelectorAll(".tag-input[data-tags]")));
88
+ if (scope instanceof HTMLElement && scope.matches(".tag-input")) containers.push(scope);
89
+ containers.push(...Array.from(scope.querySelectorAll(".tag-input")));
81
90
  for (const c of containers) setup(c);
82
91
  }
83
92
  export {
@@ -0,0 +1,25 @@
1
+ interface ThemeState {
2
+ preset: string;
3
+ dark: boolean;
4
+ style: string;
5
+ }
6
+ declare const PRESETS: readonly ["clay", "piano", "art", "vangogh", "ink", "retro"];
7
+ declare const STYLES: readonly ["modern", "retro", "terminal"];
8
+ declare const STORAGE_KEY = "icen.ui.theme";
9
+ /** 默认主题:clay + 跟随系统明暗 + modern。SSR 下返回离线兜底值。 */
10
+ declare function getDefaultTheme(): ThemeState;
11
+ /** 读 localStorage 中的持久化主题;缺失或坏数据回退默认。 */
12
+ declare function getTheme(): ThemeState;
13
+ /** 把主题应用到 <html>:先清掉所有 preset/style/dark 相关类,再按 t 挂回。 */
14
+ declare function applyTheme(t: ThemeState): void;
15
+ /** 合并当前主题与 patch,应用并持久化,返回新态。patch 中的非法枚举值回退默认。 */
16
+ declare function setTheme(patch: Partial<ThemeState>): ThemeState;
17
+ /** 切换明暗,返回新态。 */
18
+ declare function toggleDark(): ThemeState;
19
+ /** 页面加载早期调用:读持久化主题并应用,防闪烁。
20
+ * 无持久化偏好时,自动跟随系统的 prefers-color-scheme 变化(实时);
21
+ * 一旦用户显式 setTheme / toggleDark,跟随停止(持久化优先)。
22
+ * 重复调用幂等(系统监听不会重复挂)。 */
23
+ declare function initTheme(): ThemeState;
24
+
25
+ export { PRESETS, STORAGE_KEY, STYLES, type ThemeState, applyTheme, getDefaultTheme, getTheme, initTheme, setTheme, toggleDark };
@@ -62,10 +62,12 @@ function setTheme(patch) {
62
62
  function toggleDark() {
63
63
  return setTheme({ dark: !getTheme().dark });
64
64
  }
65
+ var systemListenerAttached = false;
65
66
  function initTheme() {
66
67
  const t = getTheme();
67
68
  applyTheme(t);
68
- if (isBrowser() && typeof window.matchMedia === "function" && !hasPersistedPreference()) {
69
+ if (isBrowser() && typeof window.matchMedia === "function" && !systemListenerAttached && !hasPersistedPreference()) {
70
+ systemListenerAttached = true;
69
71
  let lastDark = t.dark;
70
72
  try {
71
73
  const mql = window.matchMedia("(prefers-color-scheme: dark)");
@@ -0,0 +1,26 @@
1
+ type ToastKind = 'ok' | 'err' | 'warn' | 'info';
2
+ interface ToastOptions {
3
+ /** 保留时长(毫秒),默认 2600;带 action 时默认 6000 */
4
+ ms?: number;
5
+ /** 配合 action:可撤销 / 重试 / 查看等 */
6
+ actionLabel?: string;
7
+ /** action 触发回调;返回 false 阻止 toast 关闭 */
8
+ onAction?: () => void | Promise<void> | boolean;
9
+ }
10
+ interface ToastConfig {
11
+ /** 同屏最大堆叠,默认 5;超出从最早的开始移除 */
12
+ maxStack?: number;
13
+ }
14
+ interface ToastFn {
15
+ (message: string, kind?: ToastKind, ms?: number): void;
16
+ ok(message: string, ms?: number): void;
17
+ err(message: string, ms?: number): void;
18
+ warn(message: string, ms?: number): void;
19
+ info(message: string, ms?: number): void;
20
+ action(message: string, actionLabel: string, onAction: () => void, ms?: number): void;
21
+ dismiss(): void;
22
+ config(opts: ToastConfig): void;
23
+ }
24
+ declare const toast: ToastFn;
25
+
26
+ export { type ToastConfig, type ToastKind, type ToastOptions, toast };
@@ -16,21 +16,35 @@ function ensureContainer() {
16
16
  document.body.appendChild(el);
17
17
  return el;
18
18
  }
19
+ var KIND_CLASS = {
20
+ ok: "toast--success",
21
+ err: "toast--error",
22
+ warn: "toast--warning",
23
+ info: "toast--info"
24
+ };
25
+ function scheduleRemove(el) {
26
+ el.classList.add("is-leaving");
27
+ let done = false;
28
+ const finish = () => {
29
+ if (done) return;
30
+ done = true;
31
+ el.remove();
32
+ };
33
+ el.addEventListener("transitionend", finish, { once: true });
34
+ window.setTimeout(finish, 400);
35
+ }
19
36
  function trimStack(container) {
20
37
  for (; ; ) {
21
38
  const items = Array.from(container.querySelectorAll(":scope > .toast"));
22
39
  if (items.length <= maxStack) return;
23
40
  const oldest = items[0];
24
41
  if (!oldest) return;
25
- oldest.classList.add("leaving");
26
- let done = false;
27
- const finish = () => {
28
- if (done) return;
29
- done = true;
42
+ if (oldest.classList.contains("is-leaving")) {
30
43
  oldest.remove();
31
- };
32
- oldest.addEventListener("transitionend", finish, { once: true });
33
- window.setTimeout(finish, 400);
44
+ continue;
45
+ }
46
+ scheduleRemove(oldest);
47
+ return;
34
48
  }
35
49
  }
36
50
  function show(message, kind = "info", opts = {}) {
@@ -38,7 +52,7 @@ function show(message, kind = "info", opts = {}) {
38
52
  const { ms, actionLabel, onAction } = opts;
39
53
  const container = ensureContainer();
40
54
  const el = document.createElement("div");
41
- el.className = kind === "info" ? "toast" : `toast ${kind}`;
55
+ el.className = `toast ${KIND_CLASS[kind]}`;
42
56
  el.setAttribute("role", kind === "err" ? "alert" : "status");
43
57
  const msgNode = document.createElement("span");
44
58
  msgNode.className = "toast-msg";
@@ -58,15 +72,7 @@ function show(message, kind = "info", opts = {}) {
58
72
  const remove = () => {
59
73
  if (removed) return;
60
74
  removed = true;
61
- el.classList.add("leaving");
62
- let done = false;
63
- const finish = () => {
64
- if (done) return;
65
- done = true;
66
- el.remove();
67
- };
68
- el.addEventListener("transitionend", finish, { once: true });
69
- window.setTimeout(finish, 400);
75
+ scheduleRemove(el);
70
76
  };
71
77
  const duration = ms ?? (actionLabel ? ACTION_MS : DEFAULT_MS);
72
78
  const timer = window.setTimeout(remove, duration);
@@ -0,0 +1,4 @@
1
+ /** 为 root 下每棵 .tree 初始化(root 自身是 .tree 也算)。 */
2
+ declare function initTree(root?: ParentNode): void;
3
+
4
+ export { initTree };
@@ -88,11 +88,12 @@ function setup(tree) {
88
88
  }
89
89
  });
90
90
  }
91
- function initTree(root = document) {
91
+ function initTree(root) {
92
92
  if (typeof document === "undefined") return;
93
+ const scope = root ?? document;
93
94
  const trees = [];
94
- if (root instanceof Element && root.matches(".tree")) trees.push(root);
95
- trees.push(...Array.from(root.querySelectorAll(".tree")));
95
+ if (scope instanceof Element && scope.matches(".tree")) trees.push(scope);
96
+ trees.push(...Array.from(scope.querySelectorAll(".tree")));
96
97
  for (const t of trees) setup(t);
97
98
  }
98
99
  export {
@@ -0,0 +1,4 @@
1
+ /** 为 root 下每个 .upload 容器初始化(root 自身是 .upload 也算)。 */
2
+ declare function initUpload(root?: ParentNode): void;
3
+
4
+ export { initUpload };
@@ -1,13 +1,190 @@
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>';
21
+ function parseSvg(source) {
22
+ try {
23
+ const doc = new DOMParser().parseFromString(source, "image/svg+xml");
24
+ const svg = doc.documentElement;
25
+ if (!svg || svg.tagName.toLowerCase() !== "svg") return null;
26
+ return document.importNode(svg, true);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ function sameFile(a, b) {
32
+ return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
33
+ }
2
34
  function setup(zone) {
3
35
  const el = zone;
4
36
  if (el.__icenUploadInit) return;
5
37
  el.__icenUploadInit = true;
6
38
  const input = zone.querySelector('input[type="file"]');
7
39
  if (!input) return;
40
+ const hasList = zone.hasAttribute("data-upload-list");
41
+ const maxSize = Number(zone.getAttribute("data-max-size")) || 0;
42
+ const maxFiles = Number(zone.getAttribute("data-max-files")) || 0;
43
+ const accept = input.getAttribute("accept") ?? "";
44
+ let currentFiles = [];
45
+ const urlMap = /* @__PURE__ */ new Map();
46
+ let syncing = false;
47
+ function getListEl() {
48
+ if (!hasList) return null;
49
+ let list = zone.querySelector(".upload-list");
50
+ if (!list) {
51
+ list = document.createElement("ul");
52
+ list.className = "upload-list";
53
+ zone.appendChild(list);
54
+ }
55
+ return list;
56
+ }
57
+ function renderList() {
58
+ const list = getListEl();
59
+ if (!list) return;
60
+ urlMap.forEach((url, f) => {
61
+ if (!currentFiles.includes(f)) {
62
+ URL.revokeObjectURL(url);
63
+ urlMap.delete(f);
64
+ }
65
+ });
66
+ list.textContent = "";
67
+ if (currentFiles.length === 0) {
68
+ list.hidden = true;
69
+ return;
70
+ }
71
+ list.hidden = false;
72
+ for (const file of currentFiles) {
73
+ const li = document.createElement("li");
74
+ li.className = "upload-list-item";
75
+ const isImage = file.type.startsWith("image/");
76
+ let thumbUrl;
77
+ if (isImage) {
78
+ thumbUrl = urlMap.get(file);
79
+ if (!thumbUrl) {
80
+ try {
81
+ thumbUrl = URL.createObjectURL(file);
82
+ urlMap.set(file, thumbUrl);
83
+ } catch {
84
+ thumbUrl = void 0;
85
+ }
86
+ }
87
+ }
88
+ if (thumbUrl) {
89
+ const img = document.createElement("img");
90
+ img.className = "upload-list-item-thumb";
91
+ img.src = thumbUrl;
92
+ img.alt = file.name;
93
+ li.appendChild(img);
94
+ } else {
95
+ const icon = document.createElement("span");
96
+ icon.className = "upload-list-item-icon";
97
+ const svg = parseSvg(fileIconSvg);
98
+ if (svg) icon.appendChild(svg);
99
+ li.appendChild(icon);
100
+ }
101
+ const info = document.createElement("div");
102
+ info.className = "upload-list-item-info";
103
+ const name = document.createElement("span");
104
+ name.className = "upload-list-item-name";
105
+ name.textContent = file.name;
106
+ const size = document.createElement("span");
107
+ size.className = "upload-list-item-size";
108
+ size.textContent = formatSize(file.size);
109
+ info.appendChild(name);
110
+ info.appendChild(size);
111
+ li.appendChild(info);
112
+ const removeBtn = document.createElement("button");
113
+ removeBtn.className = "upload-list-item-x";
114
+ removeBtn.type = "button";
115
+ removeBtn.setAttribute("aria-label", `\u79FB\u9664 ${file.name}`);
116
+ const xSvg = parseSvg(xIconSvg);
117
+ if (xSvg) removeBtn.appendChild(xSvg);
118
+ removeBtn.addEventListener("click", (ev) => {
119
+ ev.stopPropagation();
120
+ currentFiles = currentFiles.filter((f) => f !== file);
121
+ zone.dispatchEvent(new CustomEvent("icen:upload-remove", { bubbles: true, detail: { file } }));
122
+ renderList();
123
+ syncInputFiles();
124
+ });
125
+ li.appendChild(removeBtn);
126
+ list.appendChild(li);
127
+ }
128
+ }
129
+ function syncInputFiles() {
130
+ try {
131
+ const dt = new DataTransfer();
132
+ for (const f of currentFiles) dt.items.add(f);
133
+ syncing = true;
134
+ input.files = dt.files;
135
+ input.dispatchEvent(new Event("change", { bubbles: true }));
136
+ } catch {
137
+ } finally {
138
+ syncing = false;
139
+ }
140
+ }
141
+ function validate(file) {
142
+ if (accept && !isAccepted(file, accept)) return "\u6587\u4EF6\u7C7B\u578B\u4E0D\u652F\u6301";
143
+ if (maxSize > 0 && file.size > maxSize) return `\u6587\u4EF6\u8D85\u8FC7 ${formatSize(maxSize)}`;
144
+ return null;
145
+ }
146
+ function handleFiles(files) {
147
+ const valid = [];
148
+ for (const file of files) {
149
+ const reason = validate(file);
150
+ if (reason) {
151
+ zone.dispatchEvent(new CustomEvent("icen:upload-error", { bubbles: true, detail: { file, reason } }));
152
+ continue;
153
+ }
154
+ valid.push(file);
155
+ }
156
+ if (valid.length === 0) return;
157
+ if (hasList) {
158
+ const fresh = [];
159
+ for (const f of valid) {
160
+ if (currentFiles.some((c) => sameFile(c, f)) || fresh.some((c) => sameFile(c, f))) continue;
161
+ fresh.push(f);
162
+ }
163
+ if (fresh.length === 0) return;
164
+ if (maxFiles > 0) {
165
+ const remaining = maxFiles - currentFiles.length;
166
+ if (remaining <= 0) {
167
+ zone.dispatchEvent(new CustomEvent("icen:upload-error", {
168
+ bubbles: true,
169
+ detail: { file: null, reason: `\u6700\u591A ${maxFiles} \u4E2A\u6587\u4EF6` }
170
+ }));
171
+ return;
172
+ }
173
+ if (fresh.length > remaining) fresh.length = remaining;
174
+ }
175
+ currentFiles.push(...fresh);
176
+ renderList();
177
+ syncInputFiles();
178
+ zone.dispatchEvent(new CustomEvent("icen:upload", { bubbles: true, detail: { files: fresh } }));
179
+ return;
180
+ }
181
+ zone.dispatchEvent(new CustomEvent("icen:upload", { bubbles: true, detail: { files: valid } }));
182
+ }
8
183
  const openPicker = () => input.click();
9
184
  zone.addEventListener("click", (ev) => {
10
185
  if (ev.target === input) return;
186
+ const tgt = ev.target;
187
+ if (tgt instanceof Element && tgt.closest(".upload-list")) return;
11
188
  openPicker();
12
189
  });
13
190
  zone.addEventListener("keydown", (ev) => {
@@ -16,37 +193,48 @@ function setup(zone) {
16
193
  openPicker();
17
194
  }
18
195
  });
196
+ input.addEventListener("change", () => {
197
+ if (syncing) return;
198
+ if (!input.files) return;
199
+ const files = Array.from(input.files);
200
+ if (hasList) {
201
+ input.value = "";
202
+ }
203
+ handleFiles(files);
204
+ });
19
205
  zone.addEventListener("dragover", (ev) => {
20
206
  ev.preventDefault();
21
207
  zone.classList.add("is-drag");
22
208
  });
23
- zone.addEventListener("dragleave", () => zone.classList.remove("is-drag"));
209
+ zone.addEventListener("dragleave", (ev) => {
210
+ if (ev.target === zone) zone.classList.remove("is-drag");
211
+ });
24
212
  zone.addEventListener("drop", (ev) => {
25
213
  ev.preventDefault();
26
214
  zone.classList.remove("is-drag");
27
215
  const files = ev.dataTransfer?.files;
28
216
  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 }));
217
+ handleFiles(Array.from(files));
218
+ if (!hasList) {
219
+ try {
220
+ const dt = new DataTransfer();
221
+ for (const f of Array.from(files)) dt.items.add(f);
222
+ syncing = true;
223
+ input.files = dt.files;
224
+ input.dispatchEvent(new Event("change", { bubbles: true }));
225
+ } catch {
226
+ } finally {
227
+ syncing = false;
228
+ }
40
229
  }
41
- const list = Array.from(files);
42
- zone.dispatchEvent(new CustomEvent("icen:upload", { bubbles: true, detail: { files: list } }));
43
230
  });
44
231
  }
45
- function initUpload(root = document) {
232
+ function initUpload(root) {
46
233
  if (typeof document === "undefined") return;
234
+ const scope = root ?? document;
47
235
  const zones = [];
48
- if (root instanceof HTMLElement && root.matches(".upload")) zones.push(root);
49
- zones.push(...Array.from(root.querySelectorAll(".upload")));
236
+ if (scope instanceof HTMLElement && scope.matches(".upload")) zones.push(scope);
237
+ zones.push(...Array.from(scope.querySelectorAll(".upload")));
50
238
  for (const z of zones) setup(z);
51
239
  }
52
240
  export {