iter(g33): 新增 upload 文件选择组件
This commit is contained in:
@@ -34,6 +34,7 @@ export { divider } from './divider.js';
|
||||
export { list } from './list.js';
|
||||
export { carousel } from './carousel.js';
|
||||
export { tree } from './tree.js';
|
||||
export { upload } from './upload.js';
|
||||
export { descriptions } from './descriptions.js';
|
||||
export { statistic } from './statistic.js';
|
||||
export { timeline } from './timeline.js';
|
||||
|
||||
@@ -420,6 +420,15 @@ export interface ListProps {
|
||||
onItemClick?: (item: ListItem, index: number) => void; // 条目点击回调;提供后条目可点击并带 hover 反馈
|
||||
}
|
||||
|
||||
/*** 文件选择入参 ***/
|
||||
export interface UploadProps {
|
||||
accept?: string; // 接受的类型(同原生 accept),如 'image/*,.pdf'
|
||||
multiple?: boolean; // 允许多选;缺省 false(单选覆盖)
|
||||
hint?: string; // 提示文案;缺省「点击或拖拽文件到此处」
|
||||
disabled?: boolean; // 禁用:不可点选、不接受拖拽
|
||||
onChange?: (files: File[]) => void; // 文件变化回调(选择/拖拽/移除后触发,传副本)
|
||||
}
|
||||
|
||||
/*** 树节点 ***/
|
||||
export interface TreeNode {
|
||||
key: string; // 唯一键(选中/展开都按 key 记)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { UploadProps } from './types.js';
|
||||
|
||||
/*** 文件选择:点击或拖拽入列,已选清单可逐个移除;只负责选文件并回传,不内置上传请求(网络层交给调用方) ***/
|
||||
export function upload(props: UploadProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const files: File[] = []; // 已选文件(顺序即选择顺序)
|
||||
const input = h('input', { type: 'file', class: 'ua-upload-input' }) as HTMLInputElement; // 隐藏的原生选择器
|
||||
if (props.accept) input.accept = props.accept;
|
||||
if (props.multiple) input.multiple = true;
|
||||
const listEl = h('div', { class: 'ua-upload-list' }) as HTMLElement; // 已选文件清单
|
||||
|
||||
/*** 按字节数换算可读体积 ***/
|
||||
const size = (bytes: number) =>
|
||||
bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
|
||||
/*** 移除指定下标的文件 ***/
|
||||
const remove = (i: number) => {
|
||||
files.splice(i, 1);
|
||||
sync();
|
||||
};
|
||||
|
||||
/*** 重绘清单并回传(选择/拖拽/移除都走这里,保证单一出口) ***/
|
||||
const sync = () => {
|
||||
listEl.replaceChildren(
|
||||
...files.map((file, i) =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'ua-upload-item' },
|
||||
h('span', { class: 'ua-upload-name' }, file.name),
|
||||
h('span', { class: 'ua-upload-size' }, size(file.size)),
|
||||
h('button', { type: 'button', class: 'ua-upload-remove', 'aria-label': `移除 ${file.name}`, onclick: () => remove(i) }, '×'),
|
||||
),
|
||||
),
|
||||
);
|
||||
props.onChange?.(files.slice()); // 回传副本,避免调用方改动内部数组
|
||||
};
|
||||
|
||||
/*** 追加文件:单选模式直接替换,多选模式追加并去重(同名同大小视为同一个) ***/
|
||||
const add = (picked: FileList | File[]) => {
|
||||
for (const file of Array.from(picked)) {
|
||||
const dup = files.some((f) => f.name === file.name && f.size === file.size);
|
||||
if (dup) continue; // 卫语句:重复文件跳过
|
||||
if (props.multiple) files.push(file);
|
||||
else files.splice(0, files.length, file); // 单选:只留最新一个
|
||||
}
|
||||
sync();
|
||||
};
|
||||
|
||||
const zone = h(
|
||||
'div',
|
||||
{
|
||||
class: props.disabled ? 'ua-upload-zone ua-upload-disabled' : 'ua-upload-zone',
|
||||
tabindex: props.disabled ? '-1' : '0',
|
||||
role: 'button',
|
||||
'aria-label': '选择文件',
|
||||
onclick: () => { if (!props.disabled) input.click(); },
|
||||
onkeydown: (e: KeyboardEvent) => {
|
||||
if (props.disabled) return; // 卫语句:禁用不响应
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } // 键盘等效点击
|
||||
},
|
||||
ondragover: (e: Event) => {
|
||||
if (props.disabled) return; // 卫语句:禁用不接受拖拽
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).classList.add('ua-upload-over');
|
||||
},
|
||||
ondragleave: (e: Event) => (e.currentTarget as HTMLElement).classList.remove('ua-upload-over'),
|
||||
ondrop: (e: Event) => {
|
||||
const ev = e as DragEvent;
|
||||
ev.preventDefault();
|
||||
(ev.currentTarget as HTMLElement).classList.remove('ua-upload-over');
|
||||
if (props.disabled || !ev.dataTransfer?.files?.length) return; // 卫语句:无文件或已禁用
|
||||
add(ev.dataTransfer.files);
|
||||
},
|
||||
},
|
||||
h('span', { class: 'ua-upload-icon', 'aria-hidden': 'true' }, '⤒'),
|
||||
h('span', { class: 'ua-upload-hint' }, props.hint ?? '点击或拖拽文件到此处'),
|
||||
input,
|
||||
);
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
if (input.files?.length) add(input.files);
|
||||
input.value = ''; // 清空以便重复选择同一文件也能触发 change
|
||||
});
|
||||
|
||||
return h('div', { class: 'ua-upload' }, zone, listEl);
|
||||
}
|
||||
@@ -55,7 +55,8 @@
|
||||
"statistic(props):统计数值,标题 + 大号数字 + 前后缀 + 环比趋势,value 变化时 rAF 滚动过渡(400ms easeOutCubic),减弱动效偏好下直落终值",
|
||||
"list(props):列表,header/footer 外壳 + 条目 avatar/title/description/extra,onItemClick 让条目可点击,items 为空回落空状态",
|
||||
"carousel(props):轮播,items 横排位移,指示点/箭头/←→ 键切换,autoplay 毫秒自动前进且悬停暂停,index 信号可外部受控",
|
||||
"tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应"
|
||||
"tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应",
|
||||
"upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求)"
|
||||
],
|
||||
"keyTypes": [
|
||||
"ButtonProps",
|
||||
@@ -1775,6 +1776,47 @@
|
||||
"键盘:↑↓ 在可见节点间移动、Home/End 到首末行(只移动焦点),Enter/空格 选中、→ 展开、← 收起;disabled 节点点击与键盘都不响应",
|
||||
"defaultExpandAll 首屏展开全部分支(外部已给 expanded 初值时以外部为准)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "upload",
|
||||
"title": "文件选择 Upload",
|
||||
"demo": "sec-upload",
|
||||
"props": [
|
||||
{
|
||||
"n": "accept",
|
||||
"t": "string",
|
||||
"d": "接受的类型(同原生 accept),如 'image/*,.pdf'"
|
||||
},
|
||||
{
|
||||
"n": "multiple",
|
||||
"t": "boolean",
|
||||
"d": "允许多选;缺省 false(单选时新选择覆盖旧的)"
|
||||
},
|
||||
{
|
||||
"n": "hint",
|
||||
"t": "string",
|
||||
"d": "提示文案;缺省「点击或拖拽文件到此处」"
|
||||
},
|
||||
{
|
||||
"n": "disabled",
|
||||
"t": "boolean",
|
||||
"d": "禁用:不可点选、不接受拖拽"
|
||||
},
|
||||
{
|
||||
"n": "onChange",
|
||||
"t": "(files: File[]) => void",
|
||||
"k": 1,
|
||||
"d": "文件变化回调:选择/拖拽/移除后都触发,回传副本"
|
||||
}
|
||||
],
|
||||
"example": "upload({ multiple: true, accept: 'image/*', hint: '拖拽图片到此处', onChange: (files) => console.log(files.map((f) => f.name)) })",
|
||||
"steps": [
|
||||
"组件只做「选文件 + 回传」,onChange 里拿 File[] 自行决定上传接口(fetch/FormData 都行)",
|
||||
"multiple: true 允许多选,重复的文件(同名同大小)自动跳过;单选模式下新选择覆盖旧文件",
|
||||
"accept 透传原生 accept 限制可选类型,但仍需在 onChange 里二次校验扩展名",
|
||||
"键盘:区域可 Tab 聚焦,Enter/空格 打开选择器;拖拽时区域高亮,拖出恢复",
|
||||
"disabled 时不可点选、不接受拖拽;已选清单的 × 会触发 onChange 回传剩余文件"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2195,3 +2195,90 @@ button.ua-step-dot:hover {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* —— 文件选择 —— */
|
||||
.ua-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ua-space-2);
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
.ua-upload-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--ua-space-1);
|
||||
padding: var(--ua-space-4) var(--ua-space-3);
|
||||
border: 1px dashed var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-md);
|
||||
background: var(--ua-color-surface);
|
||||
color: var(--ua-color-text-muted);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.ua-upload-zone:hover,
|
||||
.ua-upload-over {
|
||||
border-color: var(--ua-color-accent);
|
||||
background: color-mix(in srgb, var(--ua-color-accent) 6%, transparent);
|
||||
}
|
||||
.ua-upload-zone:focus-visible {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 40%, transparent);
|
||||
}
|
||||
.ua-upload-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.ua-upload-icon {
|
||||
font-size: var(--ua-font-size-lg);
|
||||
color: var(--ua-color-accent);
|
||||
}
|
||||
.ua-upload-hint {
|
||||
font-size: var(--ua-font-size-sm);
|
||||
}
|
||||
.ua-upload-input {
|
||||
display: none; /* 原生选择器只作为能力载体,界面自绘 */
|
||||
}
|
||||
.ua-upload-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ua-space-1);
|
||||
}
|
||||
.ua-upload-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ua-space-2);
|
||||
padding: var(--ua-space-1) var(--ua-space-2);
|
||||
border: 1px solid var(--ua-color-border);
|
||||
border-radius: var(--ua-radius-sm);
|
||||
font-size: var(--ua-font-size-sm);
|
||||
}
|
||||
.ua-upload-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--ua-color-text);
|
||||
}
|
||||
.ua-upload-size {
|
||||
color: var(--ua-color-text-muted);
|
||||
}
|
||||
.ua-upload-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--ua-color-text-muted);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.ua-upload-remove:hover {
|
||||
color: var(--ua-color-danger);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ua-upload-zone {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user