@masters-union/union-stack 0.3.5 → 0.3.7
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/cdn/loader.v1.global.js +3 -2
- package/dist/cdn/loader.v1.global.js.map +1 -1
- package/dist/{chunk-ZUVGFBHM.js → chunk-J6UVDGJO.js} +3 -3
- package/dist/{chunk-ZUVGFBHM.js.map → chunk-J6UVDGJO.js.map} +1 -1
- package/dist/{chunk-HDI26HSZ.cjs → chunk-M4COFGFO.cjs} +3 -3
- package/dist/{chunk-HDI26HSZ.cjs.map → chunk-M4COFGFO.cjs.map} +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.js +2 -2
- package/dist/picker.cjs +185 -15
- package/dist/picker.cjs.map +1 -1
- package/dist/picker.d.cts +7 -0
- package/dist/picker.d.ts +7 -0
- package/dist/picker.js +185 -15
- package/dist/picker.js.map +1 -1
- package/dist/react.cjs +2 -2
- package/dist/react.js +1 -1
- package/package.json +1 -1
package/dist/picker.cjs
CHANGED
|
@@ -334,6 +334,7 @@ var BASE_CSS = `
|
|
|
334
334
|
font-size: 11px; color: var(--us-muted);
|
|
335
335
|
font-family: var(--us-mono); letter-spacing: 0;
|
|
336
336
|
}
|
|
337
|
+
.us-actions-summary[data-tone="warn"] { color: var(--us-danger); }
|
|
337
338
|
.us-actions-buttons { display: flex; gap: 8px; }
|
|
338
339
|
|
|
339
340
|
.us-btn {
|
|
@@ -1070,6 +1071,7 @@ function iconForMime(mime) {
|
|
|
1070
1071
|
if (mime.startsWith("application/zip") || mime.includes("compressed") || mime === "application/x-tar" || mime === "application/gzip") return ICON2.archive;
|
|
1071
1072
|
return ICON2.file;
|
|
1072
1073
|
}
|
|
1074
|
+
var FOCUSABLE_SELECTOR = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
1073
1075
|
var Picker = class {
|
|
1074
1076
|
constructor(client, opts) {
|
|
1075
1077
|
this.client = client;
|
|
@@ -1092,6 +1094,10 @@ var Picker = class {
|
|
|
1092
1094
|
this.abortCtrl = new AbortController();
|
|
1093
1095
|
this.uploadStarted = false;
|
|
1094
1096
|
this.resolved = false;
|
|
1097
|
+
// Element focused before the modal opened, restored on close (a11y).
|
|
1098
|
+
this.previouslyFocused = null;
|
|
1099
|
+
// Auto-clears the transient notice line in the actions bar.
|
|
1100
|
+
this.noticeTimer = null;
|
|
1095
1101
|
// ---- mount / dom --------------------------------------------------------
|
|
1096
1102
|
this.$summary = null;
|
|
1097
1103
|
this.donePromise = new Promise((res) => {
|
|
@@ -1122,7 +1128,13 @@ var Picker = class {
|
|
|
1122
1128
|
this.opts.onCancel?.();
|
|
1123
1129
|
this.close();
|
|
1124
1130
|
}
|
|
1131
|
+
// Normalized accept string (callers may pass a string or string[]).
|
|
1132
|
+
get acceptStr() {
|
|
1133
|
+
const a = this.opts.accept;
|
|
1134
|
+
return Array.isArray(a) ? a.join(",") : a ?? "";
|
|
1135
|
+
}
|
|
1125
1136
|
mount() {
|
|
1137
|
+
this.previouslyFocused = document.activeElement ?? null;
|
|
1126
1138
|
const root = document.createElement("div");
|
|
1127
1139
|
root.className = "us-picker-backdrop";
|
|
1128
1140
|
Object.entries(themeToCssVars(this.opts.theme)).forEach(([k, v]) => {
|
|
@@ -1132,6 +1144,17 @@ var Picker = class {
|
|
|
1132
1144
|
if (e.target === root && !this.uploadStarted) this.cancel();
|
|
1133
1145
|
});
|
|
1134
1146
|
const panel = el2("div", "us-picker");
|
|
1147
|
+
this.$panel = panel;
|
|
1148
|
+
const titleId = `us-picker-title-${cryptoId()}`;
|
|
1149
|
+
panel.setAttribute("role", "dialog");
|
|
1150
|
+
panel.setAttribute("aria-modal", "true");
|
|
1151
|
+
panel.setAttribute("aria-labelledby", titleId);
|
|
1152
|
+
panel.addEventListener("keydown", (e) => {
|
|
1153
|
+
if (e.key === "Escape" && !this.uploadStarted) {
|
|
1154
|
+
e.preventDefault();
|
|
1155
|
+
this.cancel();
|
|
1156
|
+
} else if (e.key === "Tab") this.trapFocus(e);
|
|
1157
|
+
});
|
|
1135
1158
|
const header = el2("div", "us-picker-header");
|
|
1136
1159
|
const logoWrap = el2("div", "us-picker-header-logo");
|
|
1137
1160
|
if (this.opts.branding?.logoUrl) {
|
|
@@ -1144,6 +1167,7 @@ var Picker = class {
|
|
|
1144
1167
|
}
|
|
1145
1168
|
header.appendChild(logoWrap);
|
|
1146
1169
|
const title = el2("div", "us-picker-title", this.opts.branding?.title ?? DEFAULT_TITLE);
|
|
1170
|
+
title.id = titleId;
|
|
1147
1171
|
header.appendChild(title);
|
|
1148
1172
|
this.$closeBtn = document.createElement("button");
|
|
1149
1173
|
this.$closeBtn.type = "button";
|
|
@@ -1173,6 +1197,8 @@ var Picker = class {
|
|
|
1173
1197
|
const autoUpload = this.opts.autoUpload === true;
|
|
1174
1198
|
const actions = el2("div", "us-actions");
|
|
1175
1199
|
this.$summary = el2("div", "us-actions-summary", "");
|
|
1200
|
+
this.$summary.setAttribute("role", "status");
|
|
1201
|
+
this.$summary.setAttribute("aria-live", "polite");
|
|
1176
1202
|
actions.appendChild(this.$summary);
|
|
1177
1203
|
const buttons = el2("div", "us-actions-buttons");
|
|
1178
1204
|
this.$cancel = document.createElement("button");
|
|
@@ -1200,7 +1226,26 @@ var Picker = class {
|
|
|
1200
1226
|
root.appendChild(panel);
|
|
1201
1227
|
(this.opts.container ?? document.body).appendChild(root);
|
|
1202
1228
|
this.$backdrop = root;
|
|
1203
|
-
this.$panel
|
|
1229
|
+
const target = panel.querySelector('.us-source-tab[data-active="true"]') ?? (this.$deviceSource?.style.display !== "none" ? panel.querySelector(".us-dropzone") : panel.querySelector(".us-url-input")) ?? this.$closeBtn;
|
|
1230
|
+
target?.focus();
|
|
1231
|
+
}
|
|
1232
|
+
// Keeps Tab focus cycling within the modal (focus trap).
|
|
1233
|
+
trapFocus(e) {
|
|
1234
|
+
if (!this.$panel) return;
|
|
1235
|
+
const focusable = Array.from(
|
|
1236
|
+
this.$panel.querySelectorAll(FOCUSABLE_SELECTOR)
|
|
1237
|
+
).filter((node) => !node.hasAttribute("disabled") && node.offsetParent !== null);
|
|
1238
|
+
if (focusable.length === 0) return;
|
|
1239
|
+
const first = focusable[0];
|
|
1240
|
+
const last = focusable[focusable.length - 1];
|
|
1241
|
+
const active = document.activeElement;
|
|
1242
|
+
if (e.shiftKey && active === first) {
|
|
1243
|
+
e.preventDefault();
|
|
1244
|
+
last.focus();
|
|
1245
|
+
} else if (!e.shiftKey && active === last) {
|
|
1246
|
+
e.preventDefault();
|
|
1247
|
+
first.focus();
|
|
1248
|
+
}
|
|
1204
1249
|
}
|
|
1205
1250
|
// Resolves which sources to show, honoring opts.fromSources, defaulting to
|
|
1206
1251
|
// both. Empty arrays fall back to ['device'] — we never want to leave a
|
|
@@ -1213,18 +1258,34 @@ var Picker = class {
|
|
|
1213
1258
|
renderSourceTabs(sources) {
|
|
1214
1259
|
const tabs = el2("div", "us-source-tabs");
|
|
1215
1260
|
tabs.setAttribute("role", "tablist");
|
|
1216
|
-
|
|
1261
|
+
tabs.setAttribute("aria-label", "Upload source");
|
|
1262
|
+
sources.forEach((src, i) => {
|
|
1217
1263
|
const btn = document.createElement("button");
|
|
1218
1264
|
btn.type = "button";
|
|
1219
1265
|
btn.className = "us-source-tab";
|
|
1220
1266
|
btn.setAttribute("role", "tab");
|
|
1267
|
+
btn.setAttribute("aria-selected", "false");
|
|
1268
|
+
btn.tabIndex = -1;
|
|
1221
1269
|
btn.dataset.source = src;
|
|
1222
1270
|
btn.innerHTML = src === "device" ? `${ICON2.device} <span>My Device</span>` : `${ICON2.link} <span>Link</span>`;
|
|
1223
1271
|
btn.onclick = () => this.activateSource(src);
|
|
1272
|
+
btn.onkeydown = (e) => this.onTabKeydown(e, sources, i);
|
|
1224
1273
|
tabs.appendChild(btn);
|
|
1225
|
-
}
|
|
1274
|
+
});
|
|
1226
1275
|
return tabs;
|
|
1227
1276
|
}
|
|
1277
|
+
// Arrow/Home/End navigation across the source tabs.
|
|
1278
|
+
onTabKeydown(e, sources, index) {
|
|
1279
|
+
let next = index;
|
|
1280
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (index + 1) % sources.length;
|
|
1281
|
+
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (index - 1 + sources.length) % sources.length;
|
|
1282
|
+
else if (e.key === "Home") next = 0;
|
|
1283
|
+
else if (e.key === "End") next = sources.length - 1;
|
|
1284
|
+
else return;
|
|
1285
|
+
e.preventDefault();
|
|
1286
|
+
this.activateSource(sources[next]);
|
|
1287
|
+
this.$panel?.querySelector(`.us-source-tab[data-source="${sources[next]}"]`)?.focus();
|
|
1288
|
+
}
|
|
1228
1289
|
activateSource(source) {
|
|
1229
1290
|
if (this.$deviceSource) {
|
|
1230
1291
|
const active = source === "device";
|
|
@@ -1235,7 +1296,10 @@ var Picker = class {
|
|
|
1235
1296
|
}
|
|
1236
1297
|
const tabs = this.$panel?.querySelectorAll(".us-source-tab");
|
|
1237
1298
|
tabs?.forEach((t) => {
|
|
1238
|
-
|
|
1299
|
+
const isActive = t.dataset.source === source;
|
|
1300
|
+
t.dataset.active = isActive ? "true" : "false";
|
|
1301
|
+
t.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
1302
|
+
t.tabIndex = isActive ? 0 : -1;
|
|
1239
1303
|
});
|
|
1240
1304
|
}
|
|
1241
1305
|
renderUrlSource() {
|
|
@@ -1312,20 +1376,32 @@ var Picker = class {
|
|
|
1312
1376
|
dz.appendChild(el2("div", "us-dropzone-hint", "or click to browse from your device"));
|
|
1313
1377
|
const constraintBits = [];
|
|
1314
1378
|
if (this.opts.maxFileSize) constraintBits.push(`max ${formatBytes(this.opts.maxFileSize)}`);
|
|
1315
|
-
if (this.
|
|
1316
|
-
|
|
1317
|
-
const
|
|
1318
|
-
|
|
1319
|
-
|
|
1379
|
+
if (this.acceptStr) {
|
|
1380
|
+
const labels = [];
|
|
1381
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1382
|
+
for (const part of this.acceptStr.split(",")) {
|
|
1383
|
+
const raw = part.trim();
|
|
1384
|
+
if (!raw || raw === "*/*") continue;
|
|
1385
|
+
const label = mimeLabel(raw);
|
|
1386
|
+
if (label && !seen.has(label)) {
|
|
1387
|
+
seen.add(label);
|
|
1388
|
+
labels.push(label);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
if (labels.length > 0) {
|
|
1392
|
+
const MAX = 5;
|
|
1393
|
+
constraintBits.push(
|
|
1394
|
+
labels.length > MAX ? `${labels.slice(0, MAX).join(", ")} +${labels.length - MAX}` : labels.join(", ")
|
|
1395
|
+
);
|
|
1320
1396
|
}
|
|
1321
1397
|
}
|
|
1322
1398
|
if (constraintBits.length > 0) {
|
|
1323
|
-
dz.appendChild(el2("div", "us-dropzone-constraints", constraintBits.join("
|
|
1399
|
+
dz.appendChild(el2("div", "us-dropzone-constraints", constraintBits.join(" \xB7 ")));
|
|
1324
1400
|
}
|
|
1325
1401
|
const input = document.createElement("input");
|
|
1326
1402
|
input.type = "file";
|
|
1327
1403
|
input.multiple = (this.opts.maxFiles ?? 10) > 1;
|
|
1328
|
-
if (this.
|
|
1404
|
+
if (this.acceptStr) input.accept = this.acceptStr;
|
|
1329
1405
|
input.style.display = "none";
|
|
1330
1406
|
input.onchange = () => {
|
|
1331
1407
|
if (input.files) this.addFiles(Array.from(input.files));
|
|
@@ -1340,13 +1416,20 @@ var Picker = class {
|
|
|
1340
1416
|
input.click();
|
|
1341
1417
|
}
|
|
1342
1418
|
};
|
|
1343
|
-
|
|
1419
|
+
let dragDepth = 0;
|
|
1420
|
+
dz.addEventListener("dragenter", (e) => {
|
|
1344
1421
|
e.preventDefault();
|
|
1422
|
+
dragDepth++;
|
|
1345
1423
|
dz.setAttribute("data-drag", "over");
|
|
1346
1424
|
});
|
|
1347
|
-
dz.addEventListener("
|
|
1425
|
+
dz.addEventListener("dragover", (e) => e.preventDefault());
|
|
1426
|
+
dz.addEventListener("dragleave", () => {
|
|
1427
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
1428
|
+
if (dragDepth === 0) dz.removeAttribute("data-drag");
|
|
1429
|
+
});
|
|
1348
1430
|
dz.addEventListener("drop", (e) => {
|
|
1349
1431
|
e.preventDefault();
|
|
1432
|
+
dragDepth = 0;
|
|
1350
1433
|
dz.removeAttribute("data-drag");
|
|
1351
1434
|
const dropped = e.dataTransfer?.files;
|
|
1352
1435
|
if (dropped) this.addFiles(Array.from(dropped));
|
|
@@ -1358,20 +1441,50 @@ var Picker = class {
|
|
|
1358
1441
|
this.editor.close();
|
|
1359
1442
|
this.editor = null;
|
|
1360
1443
|
}
|
|
1444
|
+
if (this.noticeTimer) {
|
|
1445
|
+
clearTimeout(this.noticeTimer);
|
|
1446
|
+
this.noticeTimer = null;
|
|
1447
|
+
}
|
|
1361
1448
|
if (this.$backdrop?.parentNode) this.$backdrop.parentNode.removeChild(this.$backdrop);
|
|
1362
1449
|
this.$backdrop = null;
|
|
1363
1450
|
this.$panel = null;
|
|
1364
1451
|
for (const item of this.items) {
|
|
1365
1452
|
if (item.objectUrl) URL.revokeObjectURL(item.objectUrl);
|
|
1366
1453
|
}
|
|
1454
|
+
this.previouslyFocused?.focus?.();
|
|
1455
|
+
this.previouslyFocused = null;
|
|
1367
1456
|
this.opts.onClose?.();
|
|
1368
1457
|
}
|
|
1369
1458
|
// ---- file selection -----------------------------------------------------
|
|
1459
|
+
// Mirrors the native <input accept> matching: exact MIME, "type/*"
|
|
1460
|
+
// wildcards, and ".ext" suffixes. Drag-and-drop bypasses the input filter,
|
|
1461
|
+
// so we re-check here to keep both entry points consistent.
|
|
1462
|
+
fileMatchesAccept(file) {
|
|
1463
|
+
const tokens = this.acceptStr.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
1464
|
+
if (tokens.length === 0 || tokens.includes("*/*")) return true;
|
|
1465
|
+
const mime = (file.type || "").toLowerCase();
|
|
1466
|
+
const name = file.name.toLowerCase();
|
|
1467
|
+
return tokens.some((tok) => {
|
|
1468
|
+
if (tok.startsWith(".")) return name.endsWith(tok);
|
|
1469
|
+
if (tok.endsWith("/*")) return mime.startsWith(tok.slice(0, -1));
|
|
1470
|
+
return mime === tok;
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1370
1473
|
addFiles(files) {
|
|
1371
1474
|
const cap = this.opts.maxFiles ?? Infinity;
|
|
1475
|
+
let rejectedType = 0;
|
|
1476
|
+
const eligible = files.filter((f) => {
|
|
1477
|
+
if (this.fileMatchesAccept(f)) return true;
|
|
1478
|
+
rejectedType++;
|
|
1479
|
+
return false;
|
|
1480
|
+
});
|
|
1372
1481
|
const remaining = cap - this.items.length;
|
|
1373
|
-
|
|
1374
|
-
const
|
|
1482
|
+
const chosen = remaining > 0 ? eligible.slice(0, remaining) : [];
|
|
1483
|
+
const overCap = eligible.length - chosen.length;
|
|
1484
|
+
const notices = [];
|
|
1485
|
+
if (rejectedType > 0) notices.push(`${rejectedType} file${rejectedType > 1 ? "s" : ""} skipped \u2014 unsupported type`);
|
|
1486
|
+
if (overCap > 0) notices.push(`${overCap} file${overCap > 1 ? "s" : ""} skipped \u2014 max ${cap} allowed`);
|
|
1487
|
+
if (notices.length) this.flashNotice(notices.join(" \xB7 "));
|
|
1375
1488
|
for (const file of chosen) {
|
|
1376
1489
|
if (this.opts.maxFileSize && file.size > this.opts.maxFileSize) {
|
|
1377
1490
|
const item2 = {
|
|
@@ -1603,6 +1716,20 @@ var Picker = class {
|
|
|
1603
1716
|
const queued = this.items.filter((i) => i.state === "queued").length;
|
|
1604
1717
|
this.$confirm.disabled = queued === 0 || this.uploadStarted;
|
|
1605
1718
|
}
|
|
1719
|
+
// Transient message in the actions bar (e.g. "2 files skipped — too large").
|
|
1720
|
+
flashNotice(text) {
|
|
1721
|
+
if (!this.$summary) return;
|
|
1722
|
+
this.$summary.textContent = text;
|
|
1723
|
+
this.$summary.dataset.tone = "warn";
|
|
1724
|
+
if (this.noticeTimer) clearTimeout(this.noticeTimer);
|
|
1725
|
+
this.noticeTimer = setTimeout(() => {
|
|
1726
|
+
if (this.$summary) {
|
|
1727
|
+
this.$summary.textContent = "";
|
|
1728
|
+
delete this.$summary.dataset.tone;
|
|
1729
|
+
}
|
|
1730
|
+
this.noticeTimer = null;
|
|
1731
|
+
}, 4e3);
|
|
1732
|
+
}
|
|
1606
1733
|
// ---- upload -------------------------------------------------------------
|
|
1607
1734
|
async startUpload() {
|
|
1608
1735
|
if (this.uploadStarted) return;
|
|
@@ -1706,6 +1833,49 @@ function el2(tag, className, text) {
|
|
|
1706
1833
|
if (text !== void 0) node.textContent = text;
|
|
1707
1834
|
return node;
|
|
1708
1835
|
}
|
|
1836
|
+
var MIME_LABELS = {
|
|
1837
|
+
"application/pdf": "PDF",
|
|
1838
|
+
"application/msword": "DOC",
|
|
1839
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX",
|
|
1840
|
+
"application/vnd.ms-excel": "XLS",
|
|
1841
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX",
|
|
1842
|
+
"application/vnd.ms-powerpoint": "PPT",
|
|
1843
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "PPTX",
|
|
1844
|
+
"application/zip": "ZIP",
|
|
1845
|
+
"application/json": "JSON",
|
|
1846
|
+
"application/rtf": "RTF",
|
|
1847
|
+
"text/plain": "TXT",
|
|
1848
|
+
"text/csv": "CSV",
|
|
1849
|
+
"text/html": "HTML",
|
|
1850
|
+
"text/markdown": "MD",
|
|
1851
|
+
"image/jpeg": "JPG",
|
|
1852
|
+
"image/png": "PNG",
|
|
1853
|
+
"image/gif": "GIF",
|
|
1854
|
+
"image/webp": "WEBP",
|
|
1855
|
+
"image/svg+xml": "SVG",
|
|
1856
|
+
"image/heic": "HEIC",
|
|
1857
|
+
"video/mp4": "MP4",
|
|
1858
|
+
"audio/mpeg": "MP3"
|
|
1859
|
+
};
|
|
1860
|
+
var MIME_GROUP_LABELS = {
|
|
1861
|
+
image: "Images",
|
|
1862
|
+
video: "Video",
|
|
1863
|
+
audio: "Audio",
|
|
1864
|
+
text: "Text",
|
|
1865
|
+
font: "Fonts"
|
|
1866
|
+
};
|
|
1867
|
+
function mimeLabel(raw) {
|
|
1868
|
+
const type = raw.trim().toLowerCase();
|
|
1869
|
+
if (!type) return "";
|
|
1870
|
+
if (type.endsWith("/*") || !type.includes("/")) {
|
|
1871
|
+
const group = type.replace("/*", "");
|
|
1872
|
+
return MIME_GROUP_LABELS[group] ?? group.charAt(0).toUpperCase() + group.slice(1);
|
|
1873
|
+
}
|
|
1874
|
+
if (MIME_LABELS[type]) return MIME_LABELS[type];
|
|
1875
|
+
if (type.startsWith(".")) return type.slice(1).toUpperCase();
|
|
1876
|
+
const subtype = type.split("/")[1] ?? type;
|
|
1877
|
+
return (subtype.split("+")[0].split(".").pop() ?? subtype).toUpperCase();
|
|
1878
|
+
}
|
|
1709
1879
|
function formatBytes(n) {
|
|
1710
1880
|
if (n < 1024) return `${n} B`;
|
|
1711
1881
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|