@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.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
@@ -332,6 +332,7 @@ var BASE_CSS = `
332
332
  font-size: 11px; color: var(--us-muted);
333
333
  font-family: var(--us-mono); letter-spacing: 0;
334
334
  }
335
+ .us-actions-summary[data-tone="warn"] { color: var(--us-danger); }
335
336
  .us-actions-buttons { display: flex; gap: 8px; }
336
337
 
337
338
  .us-btn {
@@ -1068,6 +1069,7 @@ function iconForMime(mime) {
1068
1069
  if (mime.startsWith("application/zip") || mime.includes("compressed") || mime === "application/x-tar" || mime === "application/gzip") return ICON2.archive;
1069
1070
  return ICON2.file;
1070
1071
  }
1072
+ var FOCUSABLE_SELECTOR = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
1071
1073
  var Picker = class {
1072
1074
  constructor(client, opts) {
1073
1075
  this.client = client;
@@ -1090,6 +1092,10 @@ var Picker = class {
1090
1092
  this.abortCtrl = new AbortController();
1091
1093
  this.uploadStarted = false;
1092
1094
  this.resolved = false;
1095
+ // Element focused before the modal opened, restored on close (a11y).
1096
+ this.previouslyFocused = null;
1097
+ // Auto-clears the transient notice line in the actions bar.
1098
+ this.noticeTimer = null;
1093
1099
  // ---- mount / dom --------------------------------------------------------
1094
1100
  this.$summary = null;
1095
1101
  this.donePromise = new Promise((res) => {
@@ -1120,7 +1126,13 @@ var Picker = class {
1120
1126
  this.opts.onCancel?.();
1121
1127
  this.close();
1122
1128
  }
1129
+ // Normalized accept string (callers may pass a string or string[]).
1130
+ get acceptStr() {
1131
+ const a = this.opts.accept;
1132
+ return Array.isArray(a) ? a.join(",") : a ?? "";
1133
+ }
1123
1134
  mount() {
1135
+ this.previouslyFocused = document.activeElement ?? null;
1124
1136
  const root = document.createElement("div");
1125
1137
  root.className = "us-picker-backdrop";
1126
1138
  Object.entries(themeToCssVars(this.opts.theme)).forEach(([k, v]) => {
@@ -1130,6 +1142,17 @@ var Picker = class {
1130
1142
  if (e.target === root && !this.uploadStarted) this.cancel();
1131
1143
  });
1132
1144
  const panel = el2("div", "us-picker");
1145
+ this.$panel = panel;
1146
+ const titleId = `us-picker-title-${cryptoId()}`;
1147
+ panel.setAttribute("role", "dialog");
1148
+ panel.setAttribute("aria-modal", "true");
1149
+ panel.setAttribute("aria-labelledby", titleId);
1150
+ panel.addEventListener("keydown", (e) => {
1151
+ if (e.key === "Escape" && !this.uploadStarted) {
1152
+ e.preventDefault();
1153
+ this.cancel();
1154
+ } else if (e.key === "Tab") this.trapFocus(e);
1155
+ });
1133
1156
  const header = el2("div", "us-picker-header");
1134
1157
  const logoWrap = el2("div", "us-picker-header-logo");
1135
1158
  if (this.opts.branding?.logoUrl) {
@@ -1142,6 +1165,7 @@ var Picker = class {
1142
1165
  }
1143
1166
  header.appendChild(logoWrap);
1144
1167
  const title = el2("div", "us-picker-title", this.opts.branding?.title ?? DEFAULT_TITLE);
1168
+ title.id = titleId;
1145
1169
  header.appendChild(title);
1146
1170
  this.$closeBtn = document.createElement("button");
1147
1171
  this.$closeBtn.type = "button";
@@ -1171,6 +1195,8 @@ var Picker = class {
1171
1195
  const autoUpload = this.opts.autoUpload === true;
1172
1196
  const actions = el2("div", "us-actions");
1173
1197
  this.$summary = el2("div", "us-actions-summary", "");
1198
+ this.$summary.setAttribute("role", "status");
1199
+ this.$summary.setAttribute("aria-live", "polite");
1174
1200
  actions.appendChild(this.$summary);
1175
1201
  const buttons = el2("div", "us-actions-buttons");
1176
1202
  this.$cancel = document.createElement("button");
@@ -1198,7 +1224,26 @@ var Picker = class {
1198
1224
  root.appendChild(panel);
1199
1225
  (this.opts.container ?? document.body).appendChild(root);
1200
1226
  this.$backdrop = root;
1201
- this.$panel = panel;
1227
+ 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;
1228
+ target?.focus();
1229
+ }
1230
+ // Keeps Tab focus cycling within the modal (focus trap).
1231
+ trapFocus(e) {
1232
+ if (!this.$panel) return;
1233
+ const focusable = Array.from(
1234
+ this.$panel.querySelectorAll(FOCUSABLE_SELECTOR)
1235
+ ).filter((node) => !node.hasAttribute("disabled") && node.offsetParent !== null);
1236
+ if (focusable.length === 0) return;
1237
+ const first = focusable[0];
1238
+ const last = focusable[focusable.length - 1];
1239
+ const active = document.activeElement;
1240
+ if (e.shiftKey && active === first) {
1241
+ e.preventDefault();
1242
+ last.focus();
1243
+ } else if (!e.shiftKey && active === last) {
1244
+ e.preventDefault();
1245
+ first.focus();
1246
+ }
1202
1247
  }
1203
1248
  // Resolves which sources to show, honoring opts.fromSources, defaulting to
1204
1249
  // both. Empty arrays fall back to ['device'] — we never want to leave a
@@ -1211,18 +1256,34 @@ var Picker = class {
1211
1256
  renderSourceTabs(sources) {
1212
1257
  const tabs = el2("div", "us-source-tabs");
1213
1258
  tabs.setAttribute("role", "tablist");
1214
- for (const src of sources) {
1259
+ tabs.setAttribute("aria-label", "Upload source");
1260
+ sources.forEach((src, i) => {
1215
1261
  const btn = document.createElement("button");
1216
1262
  btn.type = "button";
1217
1263
  btn.className = "us-source-tab";
1218
1264
  btn.setAttribute("role", "tab");
1265
+ btn.setAttribute("aria-selected", "false");
1266
+ btn.tabIndex = -1;
1219
1267
  btn.dataset.source = src;
1220
1268
  btn.innerHTML = src === "device" ? `${ICON2.device} <span>My Device</span>` : `${ICON2.link} <span>Link</span>`;
1221
1269
  btn.onclick = () => this.activateSource(src);
1270
+ btn.onkeydown = (e) => this.onTabKeydown(e, sources, i);
1222
1271
  tabs.appendChild(btn);
1223
- }
1272
+ });
1224
1273
  return tabs;
1225
1274
  }
1275
+ // Arrow/Home/End navigation across the source tabs.
1276
+ onTabKeydown(e, sources, index) {
1277
+ let next = index;
1278
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (index + 1) % sources.length;
1279
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (index - 1 + sources.length) % sources.length;
1280
+ else if (e.key === "Home") next = 0;
1281
+ else if (e.key === "End") next = sources.length - 1;
1282
+ else return;
1283
+ e.preventDefault();
1284
+ this.activateSource(sources[next]);
1285
+ this.$panel?.querySelector(`.us-source-tab[data-source="${sources[next]}"]`)?.focus();
1286
+ }
1226
1287
  activateSource(source) {
1227
1288
  if (this.$deviceSource) {
1228
1289
  const active = source === "device";
@@ -1233,7 +1294,10 @@ var Picker = class {
1233
1294
  }
1234
1295
  const tabs = this.$panel?.querySelectorAll(".us-source-tab");
1235
1296
  tabs?.forEach((t) => {
1236
- t.dataset.active = t.dataset.source === source ? "true" : "false";
1297
+ const isActive = t.dataset.source === source;
1298
+ t.dataset.active = isActive ? "true" : "false";
1299
+ t.setAttribute("aria-selected", isActive ? "true" : "false");
1300
+ t.tabIndex = isActive ? 0 : -1;
1237
1301
  });
1238
1302
  }
1239
1303
  renderUrlSource() {
@@ -1310,11 +1374,10 @@ var Picker = class {
1310
1374
  dz.appendChild(el2("div", "us-dropzone-hint", "or click to browse from your device"));
1311
1375
  const constraintBits = [];
1312
1376
  if (this.opts.maxFileSize) constraintBits.push(`max ${formatBytes(this.opts.maxFileSize)}`);
1313
- if (this.opts.accept) {
1314
- if (typeof this.opts.accept !== "string") this.opts.accept = this.opts.accept.join(",");
1377
+ if (this.acceptStr) {
1315
1378
  const labels = [];
1316
1379
  const seen = /* @__PURE__ */ new Set();
1317
- for (const part of this.opts.accept.split(",")) {
1380
+ for (const part of this.acceptStr.split(",")) {
1318
1381
  const raw = part.trim();
1319
1382
  if (!raw || raw === "*/*") continue;
1320
1383
  const label = mimeLabel(raw);
@@ -1336,7 +1399,7 @@ var Picker = class {
1336
1399
  const input = document.createElement("input");
1337
1400
  input.type = "file";
1338
1401
  input.multiple = (this.opts.maxFiles ?? 10) > 1;
1339
- if (this.opts.accept) input.accept = this.opts.accept;
1402
+ if (this.acceptStr) input.accept = this.acceptStr;
1340
1403
  input.style.display = "none";
1341
1404
  input.onchange = () => {
1342
1405
  if (input.files) this.addFiles(Array.from(input.files));
@@ -1351,13 +1414,20 @@ var Picker = class {
1351
1414
  input.click();
1352
1415
  }
1353
1416
  };
1354
- dz.addEventListener("dragover", (e) => {
1417
+ let dragDepth = 0;
1418
+ dz.addEventListener("dragenter", (e) => {
1355
1419
  e.preventDefault();
1420
+ dragDepth++;
1356
1421
  dz.setAttribute("data-drag", "over");
1357
1422
  });
1358
- dz.addEventListener("dragleave", () => dz.removeAttribute("data-drag"));
1423
+ dz.addEventListener("dragover", (e) => e.preventDefault());
1424
+ dz.addEventListener("dragleave", () => {
1425
+ dragDepth = Math.max(0, dragDepth - 1);
1426
+ if (dragDepth === 0) dz.removeAttribute("data-drag");
1427
+ });
1359
1428
  dz.addEventListener("drop", (e) => {
1360
1429
  e.preventDefault();
1430
+ dragDepth = 0;
1361
1431
  dz.removeAttribute("data-drag");
1362
1432
  const dropped = e.dataTransfer?.files;
1363
1433
  if (dropped) this.addFiles(Array.from(dropped));
@@ -1369,20 +1439,50 @@ var Picker = class {
1369
1439
  this.editor.close();
1370
1440
  this.editor = null;
1371
1441
  }
1442
+ if (this.noticeTimer) {
1443
+ clearTimeout(this.noticeTimer);
1444
+ this.noticeTimer = null;
1445
+ }
1372
1446
  if (this.$backdrop?.parentNode) this.$backdrop.parentNode.removeChild(this.$backdrop);
1373
1447
  this.$backdrop = null;
1374
1448
  this.$panel = null;
1375
1449
  for (const item of this.items) {
1376
1450
  if (item.objectUrl) URL.revokeObjectURL(item.objectUrl);
1377
1451
  }
1452
+ this.previouslyFocused?.focus?.();
1453
+ this.previouslyFocused = null;
1378
1454
  this.opts.onClose?.();
1379
1455
  }
1380
1456
  // ---- file selection -----------------------------------------------------
1457
+ // Mirrors the native <input accept> matching: exact MIME, "type/*"
1458
+ // wildcards, and ".ext" suffixes. Drag-and-drop bypasses the input filter,
1459
+ // so we re-check here to keep both entry points consistent.
1460
+ fileMatchesAccept(file) {
1461
+ const tokens = this.acceptStr.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
1462
+ if (tokens.length === 0 || tokens.includes("*/*")) return true;
1463
+ const mime = (file.type || "").toLowerCase();
1464
+ const name = file.name.toLowerCase();
1465
+ return tokens.some((tok) => {
1466
+ if (tok.startsWith(".")) return name.endsWith(tok);
1467
+ if (tok.endsWith("/*")) return mime.startsWith(tok.slice(0, -1));
1468
+ return mime === tok;
1469
+ });
1470
+ }
1381
1471
  addFiles(files) {
1382
1472
  const cap = this.opts.maxFiles ?? Infinity;
1473
+ let rejectedType = 0;
1474
+ const eligible = files.filter((f) => {
1475
+ if (this.fileMatchesAccept(f)) return true;
1476
+ rejectedType++;
1477
+ return false;
1478
+ });
1383
1479
  const remaining = cap - this.items.length;
1384
- if (remaining <= 0) return;
1385
- const chosen = files.slice(0, remaining);
1480
+ const chosen = remaining > 0 ? eligible.slice(0, remaining) : [];
1481
+ const overCap = eligible.length - chosen.length;
1482
+ const notices = [];
1483
+ if (rejectedType > 0) notices.push(`${rejectedType} file${rejectedType > 1 ? "s" : ""} skipped \u2014 unsupported type`);
1484
+ if (overCap > 0) notices.push(`${overCap} file${overCap > 1 ? "s" : ""} skipped \u2014 max ${cap} allowed`);
1485
+ if (notices.length) this.flashNotice(notices.join(" \xB7 "));
1386
1486
  for (const file of chosen) {
1387
1487
  if (this.opts.maxFileSize && file.size > this.opts.maxFileSize) {
1388
1488
  const item2 = {
@@ -1614,6 +1714,20 @@ var Picker = class {
1614
1714
  const queued = this.items.filter((i) => i.state === "queued").length;
1615
1715
  this.$confirm.disabled = queued === 0 || this.uploadStarted;
1616
1716
  }
1717
+ // Transient message in the actions bar (e.g. "2 files skipped — too large").
1718
+ flashNotice(text) {
1719
+ if (!this.$summary) return;
1720
+ this.$summary.textContent = text;
1721
+ this.$summary.dataset.tone = "warn";
1722
+ if (this.noticeTimer) clearTimeout(this.noticeTimer);
1723
+ this.noticeTimer = setTimeout(() => {
1724
+ if (this.$summary) {
1725
+ this.$summary.textContent = "";
1726
+ delete this.$summary.dataset.tone;
1727
+ }
1728
+ this.noticeTimer = null;
1729
+ }, 4e3);
1730
+ }
1617
1731
  // ---- upload -------------------------------------------------------------
1618
1732
  async startUpload() {
1619
1733
  if (this.uploadStarted) return;