@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.d.cts
CHANGED
|
@@ -27,20 +27,26 @@ declare class Picker {
|
|
|
27
27
|
private resolved;
|
|
28
28
|
private resolvePromise;
|
|
29
29
|
private donePromise;
|
|
30
|
+
private previouslyFocused;
|
|
31
|
+
private noticeTimer;
|
|
30
32
|
constructor(client: UnionStackClient, opts: PickerOptions);
|
|
31
33
|
open(): Promise<PickResponse>;
|
|
32
34
|
close(): void;
|
|
33
35
|
cancel(): void;
|
|
34
36
|
private $summary;
|
|
37
|
+
private get acceptStr();
|
|
35
38
|
private mount;
|
|
39
|
+
private trapFocus;
|
|
36
40
|
private resolvedSources;
|
|
37
41
|
private renderSourceTabs;
|
|
42
|
+
private onTabKeydown;
|
|
38
43
|
private activateSource;
|
|
39
44
|
private renderUrlSource;
|
|
40
45
|
private handleUrlAdd;
|
|
41
46
|
private setUrlHint;
|
|
42
47
|
private renderDropzone;
|
|
43
48
|
private unmount;
|
|
49
|
+
private fileMatchesAccept;
|
|
44
50
|
private addFiles;
|
|
45
51
|
private renderItem;
|
|
46
52
|
private replaceItemFile;
|
|
@@ -50,6 +56,7 @@ declare class Picker {
|
|
|
50
56
|
private setItemState;
|
|
51
57
|
private updateSummary;
|
|
52
58
|
private refreshConfirm;
|
|
59
|
+
private flashNotice;
|
|
53
60
|
private startUpload;
|
|
54
61
|
private resolveResult;
|
|
55
62
|
}
|
package/dist/picker.d.ts
CHANGED
|
@@ -27,20 +27,26 @@ declare class Picker {
|
|
|
27
27
|
private resolved;
|
|
28
28
|
private resolvePromise;
|
|
29
29
|
private donePromise;
|
|
30
|
+
private previouslyFocused;
|
|
31
|
+
private noticeTimer;
|
|
30
32
|
constructor(client: UnionStackClient, opts: PickerOptions);
|
|
31
33
|
open(): Promise<PickResponse>;
|
|
32
34
|
close(): void;
|
|
33
35
|
cancel(): void;
|
|
34
36
|
private $summary;
|
|
37
|
+
private get acceptStr();
|
|
35
38
|
private mount;
|
|
39
|
+
private trapFocus;
|
|
36
40
|
private resolvedSources;
|
|
37
41
|
private renderSourceTabs;
|
|
42
|
+
private onTabKeydown;
|
|
38
43
|
private activateSource;
|
|
39
44
|
private renderUrlSource;
|
|
40
45
|
private handleUrlAdd;
|
|
41
46
|
private setUrlHint;
|
|
42
47
|
private renderDropzone;
|
|
43
48
|
private unmount;
|
|
49
|
+
private fileMatchesAccept;
|
|
44
50
|
private addFiles;
|
|
45
51
|
private renderItem;
|
|
46
52
|
private replaceItemFile;
|
|
@@ -50,6 +56,7 @@ declare class Picker {
|
|
|
50
56
|
private setItemState;
|
|
51
57
|
private updateSummary;
|
|
52
58
|
private refreshConfirm;
|
|
59
|
+
private flashNotice;
|
|
53
60
|
private startUpload;
|
|
54
61
|
private resolveResult;
|
|
55
62
|
}
|
package/dist/picker.js
CHANGED
|
@@ -129,6 +129,8 @@ var BASE_CSS = `
|
|
|
129
129
|
overflow: hidden;
|
|
130
130
|
animation: us-rise 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
131
131
|
}
|
|
132
|
+
/* Container is only a programmatic focus fallback \u2014 no visible focus ring. */
|
|
133
|
+
.us-picker:focus { outline: none; }
|
|
132
134
|
@keyframes us-rise {
|
|
133
135
|
from { opacity: 0; transform: translateY(8px) scale(0.985); }
|
|
134
136
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
@@ -332,6 +334,7 @@ var BASE_CSS = `
|
|
|
332
334
|
font-size: 11px; color: var(--us-muted);
|
|
333
335
|
font-family: var(--us-mono); letter-spacing: 0;
|
|
334
336
|
}
|
|
337
|
+
.us-actions-summary[data-tone="warn"] { color: var(--us-danger); }
|
|
335
338
|
.us-actions-buttons { display: flex; gap: 8px; }
|
|
336
339
|
|
|
337
340
|
.us-btn {
|
|
@@ -1068,6 +1071,7 @@ function iconForMime(mime) {
|
|
|
1068
1071
|
if (mime.startsWith("application/zip") || mime.includes("compressed") || mime === "application/x-tar" || mime === "application/gzip") return ICON2.archive;
|
|
1069
1072
|
return ICON2.file;
|
|
1070
1073
|
}
|
|
1074
|
+
var FOCUSABLE_SELECTOR = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
1071
1075
|
var Picker = class {
|
|
1072
1076
|
constructor(client, opts) {
|
|
1073
1077
|
this.client = client;
|
|
@@ -1090,6 +1094,10 @@ var Picker = class {
|
|
|
1090
1094
|
this.abortCtrl = new AbortController();
|
|
1091
1095
|
this.uploadStarted = false;
|
|
1092
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;
|
|
1093
1101
|
// ---- mount / dom --------------------------------------------------------
|
|
1094
1102
|
this.$summary = null;
|
|
1095
1103
|
this.donePromise = new Promise((res) => {
|
|
@@ -1120,7 +1128,13 @@ var Picker = class {
|
|
|
1120
1128
|
this.opts.onCancel?.();
|
|
1121
1129
|
this.close();
|
|
1122
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
|
+
}
|
|
1123
1136
|
mount() {
|
|
1137
|
+
this.previouslyFocused = document.activeElement ?? null;
|
|
1124
1138
|
const root = document.createElement("div");
|
|
1125
1139
|
root.className = "us-picker-backdrop";
|
|
1126
1140
|
Object.entries(themeToCssVars(this.opts.theme)).forEach(([k, v]) => {
|
|
@@ -1130,6 +1144,18 @@ var Picker = class {
|
|
|
1130
1144
|
if (e.target === root && !this.uploadStarted) this.cancel();
|
|
1131
1145
|
});
|
|
1132
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.tabIndex = -1;
|
|
1153
|
+
panel.addEventListener("keydown", (e) => {
|
|
1154
|
+
if (e.key === "Escape" && !this.uploadStarted) {
|
|
1155
|
+
e.preventDefault();
|
|
1156
|
+
this.cancel();
|
|
1157
|
+
} else if (e.key === "Tab") this.trapFocus(e);
|
|
1158
|
+
});
|
|
1133
1159
|
const header = el2("div", "us-picker-header");
|
|
1134
1160
|
const logoWrap = el2("div", "us-picker-header-logo");
|
|
1135
1161
|
if (this.opts.branding?.logoUrl) {
|
|
@@ -1142,6 +1168,7 @@ var Picker = class {
|
|
|
1142
1168
|
}
|
|
1143
1169
|
header.appendChild(logoWrap);
|
|
1144
1170
|
const title = el2("div", "us-picker-title", this.opts.branding?.title ?? DEFAULT_TITLE);
|
|
1171
|
+
title.id = titleId;
|
|
1145
1172
|
header.appendChild(title);
|
|
1146
1173
|
this.$closeBtn = document.createElement("button");
|
|
1147
1174
|
this.$closeBtn.type = "button";
|
|
@@ -1171,6 +1198,8 @@ var Picker = class {
|
|
|
1171
1198
|
const autoUpload = this.opts.autoUpload === true;
|
|
1172
1199
|
const actions = el2("div", "us-actions");
|
|
1173
1200
|
this.$summary = el2("div", "us-actions-summary", "");
|
|
1201
|
+
this.$summary.setAttribute("role", "status");
|
|
1202
|
+
this.$summary.setAttribute("aria-live", "polite");
|
|
1174
1203
|
actions.appendChild(this.$summary);
|
|
1175
1204
|
const buttons = el2("div", "us-actions-buttons");
|
|
1176
1205
|
this.$cancel = document.createElement("button");
|
|
@@ -1198,7 +1227,26 @@ var Picker = class {
|
|
|
1198
1227
|
root.appendChild(panel);
|
|
1199
1228
|
(this.opts.container ?? document.body).appendChild(root);
|
|
1200
1229
|
this.$backdrop = root;
|
|
1201
|
-
|
|
1230
|
+
const target = panel.querySelector('.us-source-tab[data-active="true"]') ?? panel;
|
|
1231
|
+
target.focus();
|
|
1232
|
+
}
|
|
1233
|
+
// Keeps Tab focus cycling within the modal (focus trap).
|
|
1234
|
+
trapFocus(e) {
|
|
1235
|
+
if (!this.$panel) return;
|
|
1236
|
+
const focusable = Array.from(
|
|
1237
|
+
this.$panel.querySelectorAll(FOCUSABLE_SELECTOR)
|
|
1238
|
+
).filter((node) => !node.hasAttribute("disabled") && node.offsetParent !== null);
|
|
1239
|
+
if (focusable.length === 0) return;
|
|
1240
|
+
const first = focusable[0];
|
|
1241
|
+
const last = focusable[focusable.length - 1];
|
|
1242
|
+
const active = document.activeElement;
|
|
1243
|
+
if (e.shiftKey && active === first) {
|
|
1244
|
+
e.preventDefault();
|
|
1245
|
+
last.focus();
|
|
1246
|
+
} else if (!e.shiftKey && active === last) {
|
|
1247
|
+
e.preventDefault();
|
|
1248
|
+
first.focus();
|
|
1249
|
+
}
|
|
1202
1250
|
}
|
|
1203
1251
|
// Resolves which sources to show, honoring opts.fromSources, defaulting to
|
|
1204
1252
|
// both. Empty arrays fall back to ['device'] — we never want to leave a
|
|
@@ -1211,18 +1259,34 @@ var Picker = class {
|
|
|
1211
1259
|
renderSourceTabs(sources) {
|
|
1212
1260
|
const tabs = el2("div", "us-source-tabs");
|
|
1213
1261
|
tabs.setAttribute("role", "tablist");
|
|
1214
|
-
|
|
1262
|
+
tabs.setAttribute("aria-label", "Upload source");
|
|
1263
|
+
sources.forEach((src, i) => {
|
|
1215
1264
|
const btn = document.createElement("button");
|
|
1216
1265
|
btn.type = "button";
|
|
1217
1266
|
btn.className = "us-source-tab";
|
|
1218
1267
|
btn.setAttribute("role", "tab");
|
|
1268
|
+
btn.setAttribute("aria-selected", "false");
|
|
1269
|
+
btn.tabIndex = -1;
|
|
1219
1270
|
btn.dataset.source = src;
|
|
1220
1271
|
btn.innerHTML = src === "device" ? `${ICON2.device} <span>My Device</span>` : `${ICON2.link} <span>Link</span>`;
|
|
1221
1272
|
btn.onclick = () => this.activateSource(src);
|
|
1273
|
+
btn.onkeydown = (e) => this.onTabKeydown(e, sources, i);
|
|
1222
1274
|
tabs.appendChild(btn);
|
|
1223
|
-
}
|
|
1275
|
+
});
|
|
1224
1276
|
return tabs;
|
|
1225
1277
|
}
|
|
1278
|
+
// Arrow/Home/End navigation across the source tabs.
|
|
1279
|
+
onTabKeydown(e, sources, index) {
|
|
1280
|
+
let next = index;
|
|
1281
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (index + 1) % sources.length;
|
|
1282
|
+
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (index - 1 + sources.length) % sources.length;
|
|
1283
|
+
else if (e.key === "Home") next = 0;
|
|
1284
|
+
else if (e.key === "End") next = sources.length - 1;
|
|
1285
|
+
else return;
|
|
1286
|
+
e.preventDefault();
|
|
1287
|
+
this.activateSource(sources[next]);
|
|
1288
|
+
this.$panel?.querySelector(`.us-source-tab[data-source="${sources[next]}"]`)?.focus();
|
|
1289
|
+
}
|
|
1226
1290
|
activateSource(source) {
|
|
1227
1291
|
if (this.$deviceSource) {
|
|
1228
1292
|
const active = source === "device";
|
|
@@ -1233,7 +1297,10 @@ var Picker = class {
|
|
|
1233
1297
|
}
|
|
1234
1298
|
const tabs = this.$panel?.querySelectorAll(".us-source-tab");
|
|
1235
1299
|
tabs?.forEach((t) => {
|
|
1236
|
-
|
|
1300
|
+
const isActive = t.dataset.source === source;
|
|
1301
|
+
t.dataset.active = isActive ? "true" : "false";
|
|
1302
|
+
t.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
1303
|
+
t.tabIndex = isActive ? 0 : -1;
|
|
1237
1304
|
});
|
|
1238
1305
|
}
|
|
1239
1306
|
renderUrlSource() {
|
|
@@ -1310,11 +1377,10 @@ var Picker = class {
|
|
|
1310
1377
|
dz.appendChild(el2("div", "us-dropzone-hint", "or click to browse from your device"));
|
|
1311
1378
|
const constraintBits = [];
|
|
1312
1379
|
if (this.opts.maxFileSize) constraintBits.push(`max ${formatBytes(this.opts.maxFileSize)}`);
|
|
1313
|
-
if (this.
|
|
1314
|
-
if (typeof this.opts.accept !== "string") this.opts.accept = this.opts.accept.join(",");
|
|
1380
|
+
if (this.acceptStr) {
|
|
1315
1381
|
const labels = [];
|
|
1316
1382
|
const seen = /* @__PURE__ */ new Set();
|
|
1317
|
-
for (const part of this.
|
|
1383
|
+
for (const part of this.acceptStr.split(",")) {
|
|
1318
1384
|
const raw = part.trim();
|
|
1319
1385
|
if (!raw || raw === "*/*") continue;
|
|
1320
1386
|
const label = mimeLabel(raw);
|
|
@@ -1336,7 +1402,7 @@ var Picker = class {
|
|
|
1336
1402
|
const input = document.createElement("input");
|
|
1337
1403
|
input.type = "file";
|
|
1338
1404
|
input.multiple = (this.opts.maxFiles ?? 10) > 1;
|
|
1339
|
-
if (this.
|
|
1405
|
+
if (this.acceptStr) input.accept = this.acceptStr;
|
|
1340
1406
|
input.style.display = "none";
|
|
1341
1407
|
input.onchange = () => {
|
|
1342
1408
|
if (input.files) this.addFiles(Array.from(input.files));
|
|
@@ -1351,13 +1417,20 @@ var Picker = class {
|
|
|
1351
1417
|
input.click();
|
|
1352
1418
|
}
|
|
1353
1419
|
};
|
|
1354
|
-
|
|
1420
|
+
let dragDepth = 0;
|
|
1421
|
+
dz.addEventListener("dragenter", (e) => {
|
|
1355
1422
|
e.preventDefault();
|
|
1423
|
+
dragDepth++;
|
|
1356
1424
|
dz.setAttribute("data-drag", "over");
|
|
1357
1425
|
});
|
|
1358
|
-
dz.addEventListener("
|
|
1426
|
+
dz.addEventListener("dragover", (e) => e.preventDefault());
|
|
1427
|
+
dz.addEventListener("dragleave", () => {
|
|
1428
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
1429
|
+
if (dragDepth === 0) dz.removeAttribute("data-drag");
|
|
1430
|
+
});
|
|
1359
1431
|
dz.addEventListener("drop", (e) => {
|
|
1360
1432
|
e.preventDefault();
|
|
1433
|
+
dragDepth = 0;
|
|
1361
1434
|
dz.removeAttribute("data-drag");
|
|
1362
1435
|
const dropped = e.dataTransfer?.files;
|
|
1363
1436
|
if (dropped) this.addFiles(Array.from(dropped));
|
|
@@ -1369,20 +1442,50 @@ var Picker = class {
|
|
|
1369
1442
|
this.editor.close();
|
|
1370
1443
|
this.editor = null;
|
|
1371
1444
|
}
|
|
1445
|
+
if (this.noticeTimer) {
|
|
1446
|
+
clearTimeout(this.noticeTimer);
|
|
1447
|
+
this.noticeTimer = null;
|
|
1448
|
+
}
|
|
1372
1449
|
if (this.$backdrop?.parentNode) this.$backdrop.parentNode.removeChild(this.$backdrop);
|
|
1373
1450
|
this.$backdrop = null;
|
|
1374
1451
|
this.$panel = null;
|
|
1375
1452
|
for (const item of this.items) {
|
|
1376
1453
|
if (item.objectUrl) URL.revokeObjectURL(item.objectUrl);
|
|
1377
1454
|
}
|
|
1455
|
+
this.previouslyFocused?.focus?.();
|
|
1456
|
+
this.previouslyFocused = null;
|
|
1378
1457
|
this.opts.onClose?.();
|
|
1379
1458
|
}
|
|
1380
1459
|
// ---- file selection -----------------------------------------------------
|
|
1460
|
+
// Mirrors the native <input accept> matching: exact MIME, "type/*"
|
|
1461
|
+
// wildcards, and ".ext" suffixes. Drag-and-drop bypasses the input filter,
|
|
1462
|
+
// so we re-check here to keep both entry points consistent.
|
|
1463
|
+
fileMatchesAccept(file) {
|
|
1464
|
+
const tokens = this.acceptStr.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
1465
|
+
if (tokens.length === 0 || tokens.includes("*/*")) return true;
|
|
1466
|
+
const mime = (file.type || "").toLowerCase();
|
|
1467
|
+
const name = file.name.toLowerCase();
|
|
1468
|
+
return tokens.some((tok) => {
|
|
1469
|
+
if (tok.startsWith(".")) return name.endsWith(tok);
|
|
1470
|
+
if (tok.endsWith("/*")) return mime.startsWith(tok.slice(0, -1));
|
|
1471
|
+
return mime === tok;
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1381
1474
|
addFiles(files) {
|
|
1382
1475
|
const cap = this.opts.maxFiles ?? Infinity;
|
|
1476
|
+
let rejectedType = 0;
|
|
1477
|
+
const eligible = files.filter((f) => {
|
|
1478
|
+
if (this.fileMatchesAccept(f)) return true;
|
|
1479
|
+
rejectedType++;
|
|
1480
|
+
return false;
|
|
1481
|
+
});
|
|
1383
1482
|
const remaining = cap - this.items.length;
|
|
1384
|
-
|
|
1385
|
-
const
|
|
1483
|
+
const chosen = remaining > 0 ? eligible.slice(0, remaining) : [];
|
|
1484
|
+
const overCap = eligible.length - chosen.length;
|
|
1485
|
+
const notices = [];
|
|
1486
|
+
if (rejectedType > 0) notices.push(`${rejectedType} file${rejectedType > 1 ? "s" : ""} skipped \u2014 unsupported type`);
|
|
1487
|
+
if (overCap > 0) notices.push(`${overCap} file${overCap > 1 ? "s" : ""} skipped \u2014 max ${cap} allowed`);
|
|
1488
|
+
if (notices.length) this.flashNotice(notices.join(" \xB7 "));
|
|
1386
1489
|
for (const file of chosen) {
|
|
1387
1490
|
if (this.opts.maxFileSize && file.size > this.opts.maxFileSize) {
|
|
1388
1491
|
const item2 = {
|
|
@@ -1614,6 +1717,20 @@ var Picker = class {
|
|
|
1614
1717
|
const queued = this.items.filter((i) => i.state === "queued").length;
|
|
1615
1718
|
this.$confirm.disabled = queued === 0 || this.uploadStarted;
|
|
1616
1719
|
}
|
|
1720
|
+
// Transient message in the actions bar (e.g. "2 files skipped — too large").
|
|
1721
|
+
flashNotice(text) {
|
|
1722
|
+
if (!this.$summary) return;
|
|
1723
|
+
this.$summary.textContent = text;
|
|
1724
|
+
this.$summary.dataset.tone = "warn";
|
|
1725
|
+
if (this.noticeTimer) clearTimeout(this.noticeTimer);
|
|
1726
|
+
this.noticeTimer = setTimeout(() => {
|
|
1727
|
+
if (this.$summary) {
|
|
1728
|
+
this.$summary.textContent = "";
|
|
1729
|
+
delete this.$summary.dataset.tone;
|
|
1730
|
+
}
|
|
1731
|
+
this.noticeTimer = null;
|
|
1732
|
+
}, 4e3);
|
|
1733
|
+
}
|
|
1617
1734
|
// ---- upload -------------------------------------------------------------
|
|
1618
1735
|
async startUpload() {
|
|
1619
1736
|
if (this.uploadStarted) return;
|