@masters-union/union-stack 0.3.6 → 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,11 +1376,10 @@ 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(",");
1379
+ if (this.acceptStr) {
1317
1380
  const labels = [];
1318
1381
  const seen = /* @__PURE__ */ new Set();
1319
- for (const part of this.opts.accept.split(",")) {
1382
+ for (const part of this.acceptStr.split(",")) {
1320
1383
  const raw = part.trim();
1321
1384
  if (!raw || raw === "*/*") continue;
1322
1385
  const label = mimeLabel(raw);
@@ -1338,7 +1401,7 @@ var Picker = class {
1338
1401
  const input = document.createElement("input");
1339
1402
  input.type = "file";
1340
1403
  input.multiple = (this.opts.maxFiles ?? 10) > 1;
1341
- if (this.opts.accept) input.accept = this.opts.accept;
1404
+ if (this.acceptStr) input.accept = this.acceptStr;
1342
1405
  input.style.display = "none";
1343
1406
  input.onchange = () => {
1344
1407
  if (input.files) this.addFiles(Array.from(input.files));
@@ -1353,13 +1416,20 @@ var Picker = class {
1353
1416
  input.click();
1354
1417
  }
1355
1418
  };
1356
- dz.addEventListener("dragover", (e) => {
1419
+ let dragDepth = 0;
1420
+ dz.addEventListener("dragenter", (e) => {
1357
1421
  e.preventDefault();
1422
+ dragDepth++;
1358
1423
  dz.setAttribute("data-drag", "over");
1359
1424
  });
1360
- 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
+ });
1361
1430
  dz.addEventListener("drop", (e) => {
1362
1431
  e.preventDefault();
1432
+ dragDepth = 0;
1363
1433
  dz.removeAttribute("data-drag");
1364
1434
  const dropped = e.dataTransfer?.files;
1365
1435
  if (dropped) this.addFiles(Array.from(dropped));
@@ -1371,20 +1441,50 @@ var Picker = class {
1371
1441
  this.editor.close();
1372
1442
  this.editor = null;
1373
1443
  }
1444
+ if (this.noticeTimer) {
1445
+ clearTimeout(this.noticeTimer);
1446
+ this.noticeTimer = null;
1447
+ }
1374
1448
  if (this.$backdrop?.parentNode) this.$backdrop.parentNode.removeChild(this.$backdrop);
1375
1449
  this.$backdrop = null;
1376
1450
  this.$panel = null;
1377
1451
  for (const item of this.items) {
1378
1452
  if (item.objectUrl) URL.revokeObjectURL(item.objectUrl);
1379
1453
  }
1454
+ this.previouslyFocused?.focus?.();
1455
+ this.previouslyFocused = null;
1380
1456
  this.opts.onClose?.();
1381
1457
  }
1382
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
+ }
1383
1473
  addFiles(files) {
1384
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
+ });
1385
1481
  const remaining = cap - this.items.length;
1386
- if (remaining <= 0) return;
1387
- 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 "));
1388
1488
  for (const file of chosen) {
1389
1489
  if (this.opts.maxFileSize && file.size > this.opts.maxFileSize) {
1390
1490
  const item2 = {
@@ -1616,6 +1716,20 @@ var Picker = class {
1616
1716
  const queued = this.items.filter((i) => i.state === "queued").length;
1617
1717
  this.$confirm.disabled = queued === 0 || this.uploadStarted;
1618
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
+ }
1619
1733
  // ---- upload -------------------------------------------------------------
1620
1734
  async startUpload() {
1621
1735
  if (this.uploadStarted) return;