@phantompixeldev/retrocss 3.0.1 → 3.1.0

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/retro.js CHANGED
@@ -494,7 +494,6 @@
494
494
  root.querySelectorAll(".retro-file-upload").forEach((upload) => {
495
495
  const input = upload.querySelector(".retro-file-input");
496
496
  const display = upload.querySelector(".retro-file-filename, .retro-file-display");
497
- const label = upload.querySelector(".retro-file-label");
498
497
  const drop = upload.querySelector(".retro-file-drop");
499
498
  if (!input || !display) return;
500
499
  input.addEventListener("change", function() {
@@ -507,12 +506,6 @@
507
506
  upload.classList.remove("has-files");
508
507
  }
509
508
  });
510
- if (label) {
511
- label.addEventListener("click", (e) => {
512
- e.preventDefault();
513
- input.click();
514
- });
515
- }
516
509
  if (drop) {
517
510
  drop.addEventListener("click", (e) => {
518
511
  e.preventDefault();
@@ -603,7 +596,8 @@
603
596
  newDotsContainer.className = "retro-carousel-dots";
604
597
  this.root.appendChild(newDotsContainer);
605
598
  for (let i = 0; i < this.slides.length; i++) {
606
- const dot = document.createElement("div");
599
+ const dot = document.createElement("button");
600
+ dot.type = "button";
607
601
  dot.className = "retro-carousel-dot";
608
602
  dot.setAttribute("data-index", i);
609
603
  newDotsContainer.appendChild(dot);
@@ -626,7 +620,36 @@
626
620
  dot.addEventListener("click", () => {
627
621
  this.goTo(i);
628
622
  });
623
+ if (!dot.hasAttribute("aria-label")) {
624
+ dot.setAttribute("aria-label", `Go to slide ${i + 1} of ${this.slides.length}`);
625
+ }
626
+ if (dot.tagName !== "BUTTON") {
627
+ if (!dot.hasAttribute("role")) dot.setAttribute("role", "button");
628
+ if (!dot.hasAttribute("tabindex")) dot.tabIndex = 0;
629
+ dot.addEventListener("keydown", (e) => {
630
+ if (e.key === " " || e.key === "Enter") {
631
+ e.preventDefault();
632
+ dot.click();
633
+ }
634
+ });
635
+ }
636
+ });
637
+ this.root.addEventListener("keydown", (e) => {
638
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
639
+ if (e.target.closest("input, textarea, select, [contenteditable]")) return;
640
+ e.preventDefault();
641
+ this.goTo(this.current + (e.key === "ArrowLeft" ? -1 : 1));
629
642
  });
643
+ if (this.track && !this.track.hasAttribute("aria-live")) {
644
+ this.track.setAttribute("aria-live", "polite");
645
+ this.track.setAttribute("aria-atomic", "false");
646
+ }
647
+ if (!this.root.hasAttribute("role")) {
648
+ this.root.setAttribute("role", "group");
649
+ if (!this.root.hasAttribute("aria-label")) {
650
+ this.root.setAttribute("aria-label", "Carousel");
651
+ }
652
+ }
630
653
  this.goTo(0);
631
654
  let startX, moveX;
632
655
  this.root.addEventListener("touchstart", (e) => {
@@ -660,6 +683,8 @@
660
683
  }
661
684
  this.dots.forEach((dot, i) => {
662
685
  dot.classList.toggle("active", i === idx);
686
+ if (i === idx) dot.setAttribute("aria-current", "true");
687
+ else dot.removeAttribute("aria-current");
663
688
  });
664
689
  };
665
690
  RetroCarousel.init = function() {
@@ -711,18 +736,37 @@
711
736
  e.preventDefault();
712
737
  this.activateTab(index);
713
738
  }
739
+ let newIndex = null;
714
740
  if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
715
- e.preventDefault();
716
741
  const direction = e.key === "ArrowLeft" ? -1 : 1;
717
- let newIndex = index + direction;
742
+ newIndex = index + direction;
718
743
  if (newIndex < 0) newIndex = this.tabs.length - 1;
719
744
  if (newIndex >= this.tabs.length) newIndex = 0;
745
+ } else if (e.key === "Home") {
746
+ newIndex = 0;
747
+ } else if (e.key === "End") {
748
+ newIndex = this.tabs.length - 1;
749
+ }
750
+ if (newIndex !== null) {
751
+ e.preventDefault();
752
+ this.setTabStop(newIndex);
720
753
  this.tabs[newIndex].focus();
721
754
  }
722
755
  });
723
756
  });
724
757
  this.activateTab(this.options.defaultTab);
725
758
  }
759
+ /**
760
+ * Roving tabindex: exactly one tab is in the page tab order at a time.
761
+ * Every tab used to be tabindex=0, so Tab walked through all of them one by
762
+ * one instead of stepping over the tablist and into the panel -- twelve
763
+ * stops on the demo page before the content.
764
+ */
765
+ setTabStop(index) {
766
+ this.tabs.forEach((tab, i) => {
767
+ tab.tabIndex = i === index ? 0 : -1;
768
+ });
769
+ }
726
770
  activateTab(index) {
727
771
  this.tabs.forEach((tab) => {
728
772
  tab.classList.remove(this.options.activeClass);
@@ -730,6 +774,7 @@
730
774
  });
731
775
  this.tabs[index].classList.add(this.options.activeClass);
732
776
  this.tabs[index].setAttribute("aria-selected", "true");
777
+ this.setTabStop(index);
733
778
  var visibleContent = null;
734
779
  for (var i = 0; i < this.contentElements.length; i++) {
735
780
  if (this.contentElements[i].style.display === "block") {
@@ -1020,13 +1065,28 @@
1020
1065
  });
1021
1066
 
1022
1067
  // src/js/retro.js
1023
- function readStoredTheme() {
1068
+ var FOCUSABLE_SELECTOR = 'a[href],area[href],button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),iframe,[contenteditable]:not([contenteditable="false"]),[tabindex]:not([tabindex="-1"])';
1069
+ var tooltipSeq = 0;
1070
+ function storedTheme() {
1071
+ try {
1072
+ return localStorage.getItem("retro-theme");
1073
+ } catch (e) {
1074
+ return null;
1075
+ }
1076
+ }
1077
+ function storeTheme(theme) {
1024
1078
  try {
1025
- return localStorage.getItem("retro-theme") || "light";
1079
+ localStorage.setItem("retro-theme", theme);
1026
1080
  } catch (e) {
1027
- return "light";
1028
1081
  }
1029
1082
  }
1083
+ function systemTheme() {
1084
+ if (typeof window === "undefined" || !window.matchMedia) return null;
1085
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
1086
+ }
1087
+ function readStoredTheme() {
1088
+ return storedTheme() || systemTheme() || "light";
1089
+ }
1030
1090
  var RetroCSS = {
1031
1091
  modal: modal_default,
1032
1092
  toast: toast_default,
@@ -1072,6 +1132,7 @@
1072
1132
  this.initSearchBars();
1073
1133
  this.initTagInputs();
1074
1134
  this.initThemeToggle();
1135
+ this.initSystemThemeWatch();
1075
1136
  this.applyTheme(this.theme);
1076
1137
  events_default.emit("init", { timestamp: Date.now() });
1077
1138
  this.initRatingStars();
@@ -1093,11 +1154,28 @@
1093
1154
  if (existingTooltip) existingTooltip.remove();
1094
1155
  trigger.style.position = "relative";
1095
1156
  trigger.appendChild(tooltip);
1096
- trigger.addEventListener("mouseenter", () => {
1097
- tooltip.classList.add("show");
1098
- });
1099
- trigger.addEventListener("mouseleave", () => {
1100
- tooltip.classList.remove("show");
1157
+ tooltip.setAttribute("role", "tooltip");
1158
+ if (!tooltip.id) {
1159
+ tooltipSeq += 1;
1160
+ tooltip.id = `retro-tooltip-${tooltipSeq}`;
1161
+ }
1162
+ const describedBy = trigger.getAttribute("aria-describedby");
1163
+ if (!describedBy) {
1164
+ trigger.setAttribute("aria-describedby", tooltip.id);
1165
+ } else if (!describedBy.split(/\s+/).includes(tooltip.id)) {
1166
+ trigger.setAttribute("aria-describedby", `${describedBy} ${tooltip.id}`);
1167
+ }
1168
+ if (!trigger.matches(FOCUSABLE_SELECTOR) && !trigger.hasAttribute("tabindex")) {
1169
+ trigger.tabIndex = 0;
1170
+ }
1171
+ const show = () => tooltip.classList.add("show");
1172
+ const hide = () => tooltip.classList.remove("show");
1173
+ trigger.addEventListener("mouseenter", show);
1174
+ trigger.addEventListener("mouseleave", hide);
1175
+ trigger.addEventListener("focus", show);
1176
+ trigger.addEventListener("blur", hide);
1177
+ trigger.addEventListener("keydown", (e) => {
1178
+ if (e.key === "Escape") hide();
1101
1179
  });
1102
1180
  });
1103
1181
  },
@@ -1168,6 +1246,19 @@
1168
1246
  tags.push(tagText);
1169
1247
  const removeBtn = tag.querySelector(".retro-tag-remove");
1170
1248
  if (removeBtn) {
1249
+ if (removeBtn.tagName !== "BUTTON") {
1250
+ removeBtn.setAttribute("role", "button");
1251
+ if (!removeBtn.hasAttribute("tabindex")) removeBtn.tabIndex = 0;
1252
+ removeBtn.addEventListener("keydown", (e) => {
1253
+ if (e.key === " " || e.key === "Enter") {
1254
+ e.preventDefault();
1255
+ removeBtn.click();
1256
+ }
1257
+ });
1258
+ }
1259
+ if (!removeBtn.hasAttribute("aria-label")) {
1260
+ removeBtn.setAttribute("aria-label", `Remove ${tagText}`);
1261
+ }
1171
1262
  removeBtn.addEventListener("click", () => {
1172
1263
  tag.remove();
1173
1264
  const index = tags.indexOf(tagText);
@@ -1186,9 +1277,11 @@
1186
1277
  const tag = document.createElement("span");
1187
1278
  tag.className = "retro-tag";
1188
1279
  tag.textContent = tagText;
1189
- const removeBtn = document.createElement("span");
1280
+ const removeBtn = document.createElement("button");
1281
+ removeBtn.type = "button";
1190
1282
  removeBtn.className = "retro-tag-remove";
1191
1283
  removeBtn.textContent = "\xD7";
1284
+ removeBtn.setAttribute("aria-label", `Remove ${tagText}`);
1192
1285
  removeBtn.addEventListener("click", () => {
1193
1286
  tag.remove();
1194
1287
  const index = tags.indexOf(tagText);
@@ -1222,13 +1315,31 @@
1222
1315
  toggle.addEventListener("click", () => {
1223
1316
  this.theme = this.theme === "light" ? "dark" : "light";
1224
1317
  this.applyTheme(this.theme);
1225
- localStorage.setItem("retro-theme", this.theme);
1318
+ storeTheme(this.theme);
1226
1319
  toast_default.show(`Theme switched to ${this.theme} mode!`, {
1227
1320
  type: this.theme === "dark" ? "primary" : "light"
1228
1321
  });
1229
1322
  });
1230
1323
  });
1231
1324
  },
1325
+ /**
1326
+ * Follow the OS while the visitor has expressed no preference of their own.
1327
+ * Once the toggle has been used, that choice is stored and this stops
1328
+ * applying -- flipping the system theme must not silently undo a deliberate
1329
+ * choice.
1330
+ */
1331
+ initSystemThemeWatch() {
1332
+ if (typeof window === "undefined" || !window.matchMedia) return;
1333
+ const query = window.matchMedia("(prefers-color-scheme: dark)");
1334
+ const onChange = (e) => {
1335
+ if (storedTheme()) return;
1336
+ this.theme = e.matches ? "dark" : "light";
1337
+ this.applyTheme(this.theme);
1338
+ events_default.emit("themechange", { theme: this.theme, source: "system" });
1339
+ };
1340
+ if (query.addEventListener) query.addEventListener("change", onChange);
1341
+ else if (query.addListener) query.addListener(onChange);
1342
+ },
1232
1343
  // Apply theme to document
1233
1344
  applyTheme(theme) {
1234
1345
  if (theme === "dark") {
@@ -1237,36 +1348,81 @@
1237
1348
  document.documentElement.removeAttribute("data-theme");
1238
1349
  }
1239
1350
  },
1240
- // Interactive rating stars
1351
+ /**
1352
+ * Interactive rating stars, on the ARIA radiogroup pattern.
1353
+ *
1354
+ * These were five <span>s wired to mouseenter/mouseleave/click: an input a
1355
+ * keyboard user could not reach, let alone set, with no role and no state to
1356
+ * announce. The markup is unchanged -- the roles, tabindex and key handling
1357
+ * are added here, so existing pages get the fix without editing their HTML.
1358
+ */
1241
1359
  initRatingStars() {
1242
- const ratings = document.querySelectorAll(".retro-rating");
1243
- ratings.forEach((rating) => {
1244
- const stars = rating.querySelectorAll(".retro-rating-star");
1245
- let selected = -1;
1246
- if (rating.hasAttribute("data-rating")) {
1247
- selected = parseInt(rating.getAttribute("data-rating")) - 1;
1360
+ document.querySelectorAll(".retro-rating").forEach((rating) => {
1361
+ if (rating.dataset.retroRatingBound === "true") return;
1362
+ rating.dataset.retroRatingBound = "true";
1363
+ const stars = Array.from(rating.querySelectorAll(".retro-rating-star"));
1364
+ if (!stars.length) return;
1365
+ const max = stars.length;
1366
+ const readValue = () => parseInt(rating.getAttribute("data-rating"), 10) || 0;
1367
+ rating.setAttribute("role", "radiogroup");
1368
+ if (!rating.hasAttribute("aria-label") && !rating.hasAttribute("aria-labelledby")) {
1369
+ rating.setAttribute("aria-label", `Rating out of ${max}`);
1370
+ }
1371
+ const render = (value, previewOnly) => {
1248
1372
  stars.forEach((star, i) => {
1249
- if (i <= selected) star.classList.add("selected");
1373
+ star.classList.toggle("active", previewOnly !== void 0 && i <= previewOnly);
1374
+ if (previewOnly === void 0) star.classList.remove("active");
1375
+ star.classList.toggle("selected", i < value);
1376
+ star.setAttribute("aria-checked", String(i === value - 1));
1377
+ star.tabIndex = i === (value > 0 ? value - 1 : 0) ? 0 : -1;
1250
1378
  });
1251
- }
1379
+ };
1380
+ const select = (index, focus) => {
1381
+ const value = index + 1;
1382
+ rating.setAttribute("data-rating", String(value));
1383
+ render(value);
1384
+ if (focus) stars[index].focus();
1385
+ rating.dispatchEvent(
1386
+ new CustomEvent("retro:rating", { detail: { rating: value, max }, bubbles: true })
1387
+ );
1388
+ };
1252
1389
  stars.forEach((star, idx) => {
1253
- star.addEventListener("mouseenter", () => {
1254
- stars.forEach((s, i) => {
1255
- s.classList.toggle("active", i <= idx);
1256
- });
1257
- });
1258
- star.addEventListener("mouseleave", () => {
1259
- stars.forEach((s) => s.classList.remove("active"));
1260
- });
1261
- star.addEventListener("click", () => {
1262
- selected = idx;
1263
- stars.forEach((s, i) => {
1264
- s.classList.toggle("selected", i <= idx);
1265
- });
1266
- rating.setAttribute("data-rating", idx + 1);
1267
- rating.dispatchEvent(new CustomEvent("retro:rating", { detail: { rating: idx + 1 } }));
1390
+ star.setAttribute("role", "radio");
1391
+ star.setAttribute("aria-label", `${idx + 1} of ${max}`);
1392
+ star.addEventListener("mouseenter", () => render(readValue(), idx));
1393
+ star.addEventListener("mouseleave", () => render(readValue()));
1394
+ star.addEventListener("click", () => select(idx));
1395
+ star.addEventListener("keydown", (e) => {
1396
+ const current = readValue() - 1;
1397
+ const clamp = (n) => Math.max(0, Math.min(max - 1, n));
1398
+ let next;
1399
+ switch (e.key) {
1400
+ case "ArrowRight":
1401
+ case "ArrowDown":
1402
+ next = clamp(current + 1);
1403
+ break;
1404
+ case "ArrowLeft":
1405
+ case "ArrowUp":
1406
+ next = current < 0 ? 0 : clamp(current - 1);
1407
+ break;
1408
+ case "Home":
1409
+ next = 0;
1410
+ break;
1411
+ case "End":
1412
+ next = max - 1;
1413
+ break;
1414
+ case " ":
1415
+ case "Enter":
1416
+ next = idx;
1417
+ break;
1418
+ default:
1419
+ return;
1420
+ }
1421
+ e.preventDefault();
1422
+ select(next, true);
1268
1423
  });
1269
1424
  });
1425
+ render(readValue());
1270
1426
  });
1271
1427
  }
1272
1428
  };