@masters-union/union-stack 0.3.6 → 0.3.8
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 +5 -2
- package/dist/cdn/loader.v1.global.js.map +1 -1
- package/dist/{chunk-DFXYQU7F.cjs → chunk-ACQDEKCW.cjs} +3 -3
- package/dist/{chunk-DFXYQU7F.cjs.map → chunk-ACQDEKCW.cjs.map} +1 -1
- package/dist/{chunk-OXMJVFTZ.js → chunk-TNWAUSLA.js} +3 -3
- package/dist/{chunk-OXMJVFTZ.js.map → chunk-TNWAUSLA.js.map} +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.js +2 -2
- package/dist/picker.cjs +129 -12
- 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 +129 -12
- 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
|
@@ -131,6 +131,8 @@ var BASE_CSS = `
|
|
|
131
131
|
overflow: hidden;
|
|
132
132
|
animation: us-rise 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
133
133
|
}
|
|
134
|
+
/* Container is only a programmatic focus fallback \u2014 no visible focus ring. */
|
|
135
|
+
.us-picker:focus { outline: none; }
|
|
134
136
|
@keyframes us-rise {
|
|
135
137
|
from { opacity: 0; transform: translateY(8px) scale(0.985); }
|
|
136
138
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
@@ -334,6 +336,7 @@ var BASE_CSS = `
|
|
|
334
336
|
font-size: 11px; color: var(--us-muted);
|
|
335
337
|
font-family: var(--us-mono); letter-spacing: 0;
|
|
336
338
|
}
|
|
339
|
+
.us-actions-summary[data-tone="warn"] { color: var(--us-danger); }
|
|
337
340
|
.us-actions-buttons { display: flex; gap: 8px; }
|
|
338
341
|
|
|
339
342
|
.us-btn {
|
|
@@ -1070,6 +1073,7 @@ function iconForMime(mime) {
|
|
|
1070
1073
|
if (mime.startsWith("application/zip") || mime.includes("compressed") || mime === "application/x-tar" || mime === "application/gzip") return ICON2.archive;
|
|
1071
1074
|
return ICON2.file;
|
|
1072
1075
|
}
|
|
1076
|
+
var FOCUSABLE_SELECTOR = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
1073
1077
|
var Picker = class {
|
|
1074
1078
|
constructor(client, opts) {
|
|
1075
1079
|
this.client = client;
|
|
@@ -1092,6 +1096,10 @@ var Picker = class {
|
|
|
1092
1096
|
this.abortCtrl = new AbortController();
|
|
1093
1097
|
this.uploadStarted = false;
|
|
1094
1098
|
this.resolved = false;
|
|
1099
|
+
// Element focused before the modal opened, restored on close (a11y).
|
|
1100
|
+
this.previouslyFocused = null;
|
|
1101
|
+
// Auto-clears the transient notice line in the actions bar.
|
|
1102
|
+
this.noticeTimer = null;
|
|
1095
1103
|
// ---- mount / dom --------------------------------------------------------
|
|
1096
1104
|
this.$summary = null;
|
|
1097
1105
|
this.donePromise = new Promise((res) => {
|
|
@@ -1122,7 +1130,13 @@ var Picker = class {
|
|
|
1122
1130
|
this.opts.onCancel?.();
|
|
1123
1131
|
this.close();
|
|
1124
1132
|
}
|
|
1133
|
+
// Normalized accept string (callers may pass a string or string[]).
|
|
1134
|
+
get acceptStr() {
|
|
1135
|
+
const a = this.opts.accept;
|
|
1136
|
+
return Array.isArray(a) ? a.join(",") : a ?? "";
|
|
1137
|
+
}
|
|
1125
1138
|
mount() {
|
|
1139
|
+
this.previouslyFocused = document.activeElement ?? null;
|
|
1126
1140
|
const root = document.createElement("div");
|
|
1127
1141
|
root.className = "us-picker-backdrop";
|
|
1128
1142
|
Object.entries(themeToCssVars(this.opts.theme)).forEach(([k, v]) => {
|
|
@@ -1132,6 +1146,18 @@ var Picker = class {
|
|
|
1132
1146
|
if (e.target === root && !this.uploadStarted) this.cancel();
|
|
1133
1147
|
});
|
|
1134
1148
|
const panel = el2("div", "us-picker");
|
|
1149
|
+
this.$panel = panel;
|
|
1150
|
+
const titleId = `us-picker-title-${cryptoId()}`;
|
|
1151
|
+
panel.setAttribute("role", "dialog");
|
|
1152
|
+
panel.setAttribute("aria-modal", "true");
|
|
1153
|
+
panel.setAttribute("aria-labelledby", titleId);
|
|
1154
|
+
panel.tabIndex = -1;
|
|
1155
|
+
panel.addEventListener("keydown", (e) => {
|
|
1156
|
+
if (e.key === "Escape" && !this.uploadStarted) {
|
|
1157
|
+
e.preventDefault();
|
|
1158
|
+
this.cancel();
|
|
1159
|
+
} else if (e.key === "Tab") this.trapFocus(e);
|
|
1160
|
+
});
|
|
1135
1161
|
const header = el2("div", "us-picker-header");
|
|
1136
1162
|
const logoWrap = el2("div", "us-picker-header-logo");
|
|
1137
1163
|
if (this.opts.branding?.logoUrl) {
|
|
@@ -1144,6 +1170,7 @@ var Picker = class {
|
|
|
1144
1170
|
}
|
|
1145
1171
|
header.appendChild(logoWrap);
|
|
1146
1172
|
const title = el2("div", "us-picker-title", this.opts.branding?.title ?? DEFAULT_TITLE);
|
|
1173
|
+
title.id = titleId;
|
|
1147
1174
|
header.appendChild(title);
|
|
1148
1175
|
this.$closeBtn = document.createElement("button");
|
|
1149
1176
|
this.$closeBtn.type = "button";
|
|
@@ -1173,6 +1200,8 @@ var Picker = class {
|
|
|
1173
1200
|
const autoUpload = this.opts.autoUpload === true;
|
|
1174
1201
|
const actions = el2("div", "us-actions");
|
|
1175
1202
|
this.$summary = el2("div", "us-actions-summary", "");
|
|
1203
|
+
this.$summary.setAttribute("role", "status");
|
|
1204
|
+
this.$summary.setAttribute("aria-live", "polite");
|
|
1176
1205
|
actions.appendChild(this.$summary);
|
|
1177
1206
|
const buttons = el2("div", "us-actions-buttons");
|
|
1178
1207
|
this.$cancel = document.createElement("button");
|
|
@@ -1200,7 +1229,26 @@ var Picker = class {
|
|
|
1200
1229
|
root.appendChild(panel);
|
|
1201
1230
|
(this.opts.container ?? document.body).appendChild(root);
|
|
1202
1231
|
this.$backdrop = root;
|
|
1203
|
-
|
|
1232
|
+
const target = panel.querySelector('.us-source-tab[data-active="true"]') ?? panel;
|
|
1233
|
+
target.focus();
|
|
1234
|
+
}
|
|
1235
|
+
// Keeps Tab focus cycling within the modal (focus trap).
|
|
1236
|
+
trapFocus(e) {
|
|
1237
|
+
if (!this.$panel) return;
|
|
1238
|
+
const focusable = Array.from(
|
|
1239
|
+
this.$panel.querySelectorAll(FOCUSABLE_SELECTOR)
|
|
1240
|
+
).filter((node) => !node.hasAttribute("disabled") && node.offsetParent !== null);
|
|
1241
|
+
if (focusable.length === 0) return;
|
|
1242
|
+
const first = focusable[0];
|
|
1243
|
+
const last = focusable[focusable.length - 1];
|
|
1244
|
+
const active = document.activeElement;
|
|
1245
|
+
if (e.shiftKey && active === first) {
|
|
1246
|
+
e.preventDefault();
|
|
1247
|
+
last.focus();
|
|
1248
|
+
} else if (!e.shiftKey && active === last) {
|
|
1249
|
+
e.preventDefault();
|
|
1250
|
+
first.focus();
|
|
1251
|
+
}
|
|
1204
1252
|
}
|
|
1205
1253
|
// Resolves which sources to show, honoring opts.fromSources, defaulting to
|
|
1206
1254
|
// both. Empty arrays fall back to ['device'] — we never want to leave a
|
|
@@ -1213,18 +1261,34 @@ var Picker = class {
|
|
|
1213
1261
|
renderSourceTabs(sources) {
|
|
1214
1262
|
const tabs = el2("div", "us-source-tabs");
|
|
1215
1263
|
tabs.setAttribute("role", "tablist");
|
|
1216
|
-
|
|
1264
|
+
tabs.setAttribute("aria-label", "Upload source");
|
|
1265
|
+
sources.forEach((src, i) => {
|
|
1217
1266
|
const btn = document.createElement("button");
|
|
1218
1267
|
btn.type = "button";
|
|
1219
1268
|
btn.className = "us-source-tab";
|
|
1220
1269
|
btn.setAttribute("role", "tab");
|
|
1270
|
+
btn.setAttribute("aria-selected", "false");
|
|
1271
|
+
btn.tabIndex = -1;
|
|
1221
1272
|
btn.dataset.source = src;
|
|
1222
1273
|
btn.innerHTML = src === "device" ? `${ICON2.device} <span>My Device</span>` : `${ICON2.link} <span>Link</span>`;
|
|
1223
1274
|
btn.onclick = () => this.activateSource(src);
|
|
1275
|
+
btn.onkeydown = (e) => this.onTabKeydown(e, sources, i);
|
|
1224
1276
|
tabs.appendChild(btn);
|
|
1225
|
-
}
|
|
1277
|
+
});
|
|
1226
1278
|
return tabs;
|
|
1227
1279
|
}
|
|
1280
|
+
// Arrow/Home/End navigation across the source tabs.
|
|
1281
|
+
onTabKeydown(e, sources, index) {
|
|
1282
|
+
let next = index;
|
|
1283
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (index + 1) % sources.length;
|
|
1284
|
+
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (index - 1 + sources.length) % sources.length;
|
|
1285
|
+
else if (e.key === "Home") next = 0;
|
|
1286
|
+
else if (e.key === "End") next = sources.length - 1;
|
|
1287
|
+
else return;
|
|
1288
|
+
e.preventDefault();
|
|
1289
|
+
this.activateSource(sources[next]);
|
|
1290
|
+
this.$panel?.querySelector(`.us-source-tab[data-source="${sources[next]}"]`)?.focus();
|
|
1291
|
+
}
|
|
1228
1292
|
activateSource(source) {
|
|
1229
1293
|
if (this.$deviceSource) {
|
|
1230
1294
|
const active = source === "device";
|
|
@@ -1235,7 +1299,10 @@ var Picker = class {
|
|
|
1235
1299
|
}
|
|
1236
1300
|
const tabs = this.$panel?.querySelectorAll(".us-source-tab");
|
|
1237
1301
|
tabs?.forEach((t) => {
|
|
1238
|
-
|
|
1302
|
+
const isActive = t.dataset.source === source;
|
|
1303
|
+
t.dataset.active = isActive ? "true" : "false";
|
|
1304
|
+
t.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
1305
|
+
t.tabIndex = isActive ? 0 : -1;
|
|
1239
1306
|
});
|
|
1240
1307
|
}
|
|
1241
1308
|
renderUrlSource() {
|
|
@@ -1312,11 +1379,10 @@ var Picker = class {
|
|
|
1312
1379
|
dz.appendChild(el2("div", "us-dropzone-hint", "or click to browse from your device"));
|
|
1313
1380
|
const constraintBits = [];
|
|
1314
1381
|
if (this.opts.maxFileSize) constraintBits.push(`max ${formatBytes(this.opts.maxFileSize)}`);
|
|
1315
|
-
if (this.
|
|
1316
|
-
if (typeof this.opts.accept !== "string") this.opts.accept = this.opts.accept.join(",");
|
|
1382
|
+
if (this.acceptStr) {
|
|
1317
1383
|
const labels = [];
|
|
1318
1384
|
const seen = /* @__PURE__ */ new Set();
|
|
1319
|
-
for (const part of this.
|
|
1385
|
+
for (const part of this.acceptStr.split(",")) {
|
|
1320
1386
|
const raw = part.trim();
|
|
1321
1387
|
if (!raw || raw === "*/*") continue;
|
|
1322
1388
|
const label = mimeLabel(raw);
|
|
@@ -1338,7 +1404,7 @@ var Picker = class {
|
|
|
1338
1404
|
const input = document.createElement("input");
|
|
1339
1405
|
input.type = "file";
|
|
1340
1406
|
input.multiple = (this.opts.maxFiles ?? 10) > 1;
|
|
1341
|
-
if (this.
|
|
1407
|
+
if (this.acceptStr) input.accept = this.acceptStr;
|
|
1342
1408
|
input.style.display = "none";
|
|
1343
1409
|
input.onchange = () => {
|
|
1344
1410
|
if (input.files) this.addFiles(Array.from(input.files));
|
|
@@ -1353,13 +1419,20 @@ var Picker = class {
|
|
|
1353
1419
|
input.click();
|
|
1354
1420
|
}
|
|
1355
1421
|
};
|
|
1356
|
-
|
|
1422
|
+
let dragDepth = 0;
|
|
1423
|
+
dz.addEventListener("dragenter", (e) => {
|
|
1357
1424
|
e.preventDefault();
|
|
1425
|
+
dragDepth++;
|
|
1358
1426
|
dz.setAttribute("data-drag", "over");
|
|
1359
1427
|
});
|
|
1360
|
-
dz.addEventListener("
|
|
1428
|
+
dz.addEventListener("dragover", (e) => e.preventDefault());
|
|
1429
|
+
dz.addEventListener("dragleave", () => {
|
|
1430
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
1431
|
+
if (dragDepth === 0) dz.removeAttribute("data-drag");
|
|
1432
|
+
});
|
|
1361
1433
|
dz.addEventListener("drop", (e) => {
|
|
1362
1434
|
e.preventDefault();
|
|
1435
|
+
dragDepth = 0;
|
|
1363
1436
|
dz.removeAttribute("data-drag");
|
|
1364
1437
|
const dropped = e.dataTransfer?.files;
|
|
1365
1438
|
if (dropped) this.addFiles(Array.from(dropped));
|
|
@@ -1371,20 +1444,50 @@ var Picker = class {
|
|
|
1371
1444
|
this.editor.close();
|
|
1372
1445
|
this.editor = null;
|
|
1373
1446
|
}
|
|
1447
|
+
if (this.noticeTimer) {
|
|
1448
|
+
clearTimeout(this.noticeTimer);
|
|
1449
|
+
this.noticeTimer = null;
|
|
1450
|
+
}
|
|
1374
1451
|
if (this.$backdrop?.parentNode) this.$backdrop.parentNode.removeChild(this.$backdrop);
|
|
1375
1452
|
this.$backdrop = null;
|
|
1376
1453
|
this.$panel = null;
|
|
1377
1454
|
for (const item of this.items) {
|
|
1378
1455
|
if (item.objectUrl) URL.revokeObjectURL(item.objectUrl);
|
|
1379
1456
|
}
|
|
1457
|
+
this.previouslyFocused?.focus?.();
|
|
1458
|
+
this.previouslyFocused = null;
|
|
1380
1459
|
this.opts.onClose?.();
|
|
1381
1460
|
}
|
|
1382
1461
|
// ---- file selection -----------------------------------------------------
|
|
1462
|
+
// Mirrors the native <input accept> matching: exact MIME, "type/*"
|
|
1463
|
+
// wildcards, and ".ext" suffixes. Drag-and-drop bypasses the input filter,
|
|
1464
|
+
// so we re-check here to keep both entry points consistent.
|
|
1465
|
+
fileMatchesAccept(file) {
|
|
1466
|
+
const tokens = this.acceptStr.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
1467
|
+
if (tokens.length === 0 || tokens.includes("*/*")) return true;
|
|
1468
|
+
const mime = (file.type || "").toLowerCase();
|
|
1469
|
+
const name = file.name.toLowerCase();
|
|
1470
|
+
return tokens.some((tok) => {
|
|
1471
|
+
if (tok.startsWith(".")) return name.endsWith(tok);
|
|
1472
|
+
if (tok.endsWith("/*")) return mime.startsWith(tok.slice(0, -1));
|
|
1473
|
+
return mime === tok;
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1383
1476
|
addFiles(files) {
|
|
1384
1477
|
const cap = this.opts.maxFiles ?? Infinity;
|
|
1478
|
+
let rejectedType = 0;
|
|
1479
|
+
const eligible = files.filter((f) => {
|
|
1480
|
+
if (this.fileMatchesAccept(f)) return true;
|
|
1481
|
+
rejectedType++;
|
|
1482
|
+
return false;
|
|
1483
|
+
});
|
|
1385
1484
|
const remaining = cap - this.items.length;
|
|
1386
|
-
|
|
1387
|
-
const
|
|
1485
|
+
const chosen = remaining > 0 ? eligible.slice(0, remaining) : [];
|
|
1486
|
+
const overCap = eligible.length - chosen.length;
|
|
1487
|
+
const notices = [];
|
|
1488
|
+
if (rejectedType > 0) notices.push(`${rejectedType} file${rejectedType > 1 ? "s" : ""} skipped \u2014 unsupported type`);
|
|
1489
|
+
if (overCap > 0) notices.push(`${overCap} file${overCap > 1 ? "s" : ""} skipped \u2014 max ${cap} allowed`);
|
|
1490
|
+
if (notices.length) this.flashNotice(notices.join(" \xB7 "));
|
|
1388
1491
|
for (const file of chosen) {
|
|
1389
1492
|
if (this.opts.maxFileSize && file.size > this.opts.maxFileSize) {
|
|
1390
1493
|
const item2 = {
|
|
@@ -1616,6 +1719,20 @@ var Picker = class {
|
|
|
1616
1719
|
const queued = this.items.filter((i) => i.state === "queued").length;
|
|
1617
1720
|
this.$confirm.disabled = queued === 0 || this.uploadStarted;
|
|
1618
1721
|
}
|
|
1722
|
+
// Transient message in the actions bar (e.g. "2 files skipped — too large").
|
|
1723
|
+
flashNotice(text) {
|
|
1724
|
+
if (!this.$summary) return;
|
|
1725
|
+
this.$summary.textContent = text;
|
|
1726
|
+
this.$summary.dataset.tone = "warn";
|
|
1727
|
+
if (this.noticeTimer) clearTimeout(this.noticeTimer);
|
|
1728
|
+
this.noticeTimer = setTimeout(() => {
|
|
1729
|
+
if (this.$summary) {
|
|
1730
|
+
this.$summary.textContent = "";
|
|
1731
|
+
delete this.$summary.dataset.tone;
|
|
1732
|
+
}
|
|
1733
|
+
this.noticeTimer = null;
|
|
1734
|
+
}, 4e3);
|
|
1735
|
+
}
|
|
1619
1736
|
// ---- upload -------------------------------------------------------------
|
|
1620
1737
|
async startUpload() {
|
|
1621
1738
|
if (this.uploadStarted) return;
|