@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/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
- this.$panel = panel;
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
- for (const src of sources) {
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
- t.dataset.active = t.dataset.source === source ? "true" : "false";
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.opts.accept) {
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.opts.accept.split(",")) {
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.opts.accept) input.accept = this.opts.accept;
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
- dz.addEventListener("dragover", (e) => {
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("dragleave", () => dz.removeAttribute("data-drag"));
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
- if (remaining <= 0) return;
1387
- const chosen = files.slice(0, remaining);
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;