@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/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 = 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
- for (const src of sources) {
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
- t.dataset.active = t.dataset.source === source ? "true" : "false";
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.opts.accept) {
1316
- if (typeof this.opts.accept !== "string") this.opts.accept = this.opts.accept.join(",");
1317
- const types = this.opts.accept.split(",").map((s) => s.trim()).filter((s) => s && s !== "*/*").map((s) => s.replace("/*", ""));
1318
- if (types.length > 0 && types.length <= 4) {
1319
- constraintBits.push(types.join(" \xB7 "));
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(" \xB7 ")));
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.opts.accept) input.accept = this.opts.accept;
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
- dz.addEventListener("dragover", (e) => {
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("dragleave", () => dz.removeAttribute("data-drag"));
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
- if (remaining <= 0) return;
1374
- const chosen = files.slice(0, remaining);
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`;