@unabridged/midwest 0.22.1 → 0.23.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.
Files changed (25) hide show
  1. package/README.md +1 -1
  2. package/app/assets/javascript/midwest/index.ts +8 -0
  3. package/app/assets/javascript/midwest/keyboard_navigation.ts +68 -0
  4. package/app/assets/javascript/midwest.js +476 -37
  5. package/app/assets/javascript/midwest.js.map +1 -1
  6. package/app/assets/stylesheets/midwest.css +1 -1
  7. package/app/assets/stylesheets/midwest.tailwind.css +13 -1
  8. package/dist/css/midwest.css +1 -1
  9. package/dist/javascript/collection/app/assets/javascript/midwest/index.js +8 -0
  10. package/dist/javascript/collection/app/assets/javascript/midwest/index.js.map +1 -1
  11. package/dist/javascript/collection/app/assets/javascript/midwest/keyboard_navigation.js +42 -0
  12. package/dist/javascript/collection/app/assets/javascript/midwest/keyboard_navigation.js.map +1 -0
  13. package/dist/javascript/collection/app/components/midwest/dropdown_component/dropdown_component_controller.js +8 -37
  14. package/dist/javascript/collection/app/components/midwest/dropdown_component/dropdown_component_controller.js.map +1 -1
  15. package/dist/javascript/collection/app/components/midwest/focus_trap_component/focus_trap_component_controller.js +83 -0
  16. package/dist/javascript/collection/app/components/midwest/focus_trap_component/focus_trap_component_controller.js.map +1 -0
  17. package/dist/javascript/collection/app/components/midwest/form/switch_component/theme_switch_controller.js +42 -0
  18. package/dist/javascript/collection/app/components/midwest/form/switch_component/theme_switch_controller.js.map +1 -0
  19. package/dist/javascript/collection/app/components/midwest/intersection_observer_component/intersection_observer_component_controller.js +43 -0
  20. package/dist/javascript/collection/app/components/midwest/intersection_observer_component/intersection_observer_component_controller.js.map +1 -0
  21. package/dist/javascript/collection/app/components/midwest/onboarding_component/onboarding_component_controller.js +273 -0
  22. package/dist/javascript/collection/app/components/midwest/onboarding_component/onboarding_component_controller.js.map +1 -0
  23. package/dist/javascript/midwest.js +476 -37
  24. package/dist/javascript/midwest.js.map +1 -1
  25. package/package.json +1 -1
@@ -2511,6 +2511,46 @@ const computePosition = (reference, floating, options) => {
2511
2511
  });
2512
2512
  };
2513
2513
 
2514
+ const NAVIGATION_KEYS = ["ArrowDown", "ArrowUp", "Home", "End", "Escape", "Tab"];
2515
+ function handleRovingFocusKeydown(event, items, options = {}) {
2516
+ if (items.length === 0)
2517
+ return false;
2518
+ if (!NAVIGATION_KEYS.includes(event.key))
2519
+ return false;
2520
+ const { onEscape, onTab, loop = true } = options;
2521
+ const current = items.findIndex((item) => item === document.activeElement);
2522
+ switch (event.key) {
2523
+ case "ArrowDown": {
2524
+ event.preventDefault();
2525
+ const next = current < items.length - 1 ? current + 1 : loop ? 0 : items.length - 1;
2526
+ items[next].focus();
2527
+ break;
2528
+ }
2529
+ case "ArrowUp": {
2530
+ event.preventDefault();
2531
+ const prev = current > 0 ? current - 1 : loop ? items.length - 1 : 0;
2532
+ items[prev].focus();
2533
+ break;
2534
+ }
2535
+ case "Home":
2536
+ event.preventDefault();
2537
+ items[0].focus();
2538
+ break;
2539
+ case "End":
2540
+ event.preventDefault();
2541
+ items[items.length - 1].focus();
2542
+ break;
2543
+ case "Escape":
2544
+ event.preventDefault();
2545
+ onEscape?.();
2546
+ break;
2547
+ case "Tab":
2548
+ onTab?.();
2549
+ break;
2550
+ }
2551
+ return true;
2552
+ }
2553
+
2514
2554
  class Dropdown extends Controller {
2515
2555
  static values = {
2516
2556
  computed: { type: Boolean, default: false },
@@ -2564,45 +2604,15 @@ class Dropdown extends Controller {
2564
2604
  }
2565
2605
  };
2566
2606
  handleKeydown = (event) => {
2567
- const items = this.menuItems;
2568
- if (items.length === 0)
2569
- return;
2570
- if (!["ArrowDown", "ArrowUp", "Home", "End", "Escape", "Tab"].includes(event.key))
2571
- return;
2572
- event.stopPropagation();
2573
- const currentIndex = items.findIndex((item) => item === document.activeElement);
2574
- switch (event.key) {
2575
- case "ArrowDown": {
2576
- event.preventDefault();
2577
- const next = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
2578
- items[next].focus();
2579
- break;
2580
- }
2581
- case "ArrowUp": {
2582
- event.preventDefault();
2583
- const prev = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
2584
- items[prev].focus();
2585
- break;
2586
- }
2587
- case "Home":
2588
- event.preventDefault();
2589
- items[0].focus();
2590
- break;
2591
- case "End":
2592
- event.preventDefault();
2593
- items[items.length - 1].focus();
2594
- break;
2595
- case "Escape":
2596
- event.preventDefault();
2607
+ const handled = handleRovingFocusKeydown(event, this.menuItems, {
2608
+ onEscape: () => {
2597
2609
  this.element.hidePopover();
2598
2610
  requestAnimationFrame(() => this.buttonEl?.focus());
2599
- break;
2600
- case "Tab":
2601
- this.element.hidePopover();
2602
- break;
2603
- default:
2604
- return;
2605
- }
2611
+ },
2612
+ onTab: () => this.element.hidePopover()
2613
+ });
2614
+ if (handled)
2615
+ event.stopPropagation();
2606
2616
  };
2607
2617
  get menuItems() {
2608
2618
  return Array.from(
@@ -6555,6 +6565,431 @@ class TimePicker extends Controller {
6555
6565
  }
6556
6566
  }
6557
6567
 
6568
+ class ViewportObserver extends Controller {
6569
+ static values = {
6570
+ threshold: { type: Number, default: 0 },
6571
+ rootMargin: { type: String, default: "0px" },
6572
+ once: { type: Boolean, default: false },
6573
+ visibleClass: { type: String, default: "" }
6574
+ };
6575
+ observer = null;
6576
+ connect() {
6577
+ this.observer = new IntersectionObserver(
6578
+ (entries) => {
6579
+ for (const entry of entries)
6580
+ this.handle(entry);
6581
+ },
6582
+ { threshold: this.thresholdValue, rootMargin: this.rootMarginValue }
6583
+ );
6584
+ this.observer.observe(this.element);
6585
+ }
6586
+ disconnect() {
6587
+ this.observer?.disconnect();
6588
+ this.observer = null;
6589
+ }
6590
+ handle(entry) {
6591
+ if (entry.isIntersecting) {
6592
+ this.element.setAttribute("data-intersecting", "true");
6593
+ if (this.visibleClassValue)
6594
+ this.element.classList.add(this.visibleClassValue);
6595
+ this.dispatch("enter", { detail: { entry }, bubbles: true });
6596
+ if (this.onceValue)
6597
+ this.disconnect();
6598
+ } else {
6599
+ this.element.setAttribute("data-intersecting", "false");
6600
+ if (this.visibleClassValue)
6601
+ this.element.classList.remove(this.visibleClassValue);
6602
+ this.dispatch("leave", { detail: { entry }, bubbles: true });
6603
+ }
6604
+ }
6605
+ }
6606
+
6607
+ const FOCUSABLE$1 = [
6608
+ "a[href]",
6609
+ "button:not([disabled])",
6610
+ "textarea:not([disabled])",
6611
+ "input:not([disabled])",
6612
+ "select:not([disabled])",
6613
+ '[tabindex]:not([tabindex="-1"])'
6614
+ ].join(", ");
6615
+ class FocusTrap extends Controller {
6616
+ static values = {
6617
+ active: { type: Boolean, default: true },
6618
+ returnFocus: { type: Boolean, default: true },
6619
+ initialFocus: { type: String, default: "" }
6620
+ };
6621
+ engaged = false;
6622
+ previousFocus = null;
6623
+ // Fires on connect for the initial value and on every subsequent change,
6624
+ // so it is the single entry point for engaging/releasing the trap.
6625
+ activeValueChanged(active) {
6626
+ if (active)
6627
+ this.engage();
6628
+ else
6629
+ this.release();
6630
+ }
6631
+ disconnect() {
6632
+ this.release();
6633
+ }
6634
+ engage() {
6635
+ if (this.engaged)
6636
+ return;
6637
+ this.engaged = true;
6638
+ this.previousFocus = document.activeElement;
6639
+ this.element.addEventListener("keydown", this.handleKeydown);
6640
+ requestAnimationFrame(() => {
6641
+ if (this.engaged)
6642
+ this.focusInitial();
6643
+ });
6644
+ }
6645
+ release() {
6646
+ if (!this.engaged)
6647
+ return;
6648
+ this.engaged = false;
6649
+ this.element.removeEventListener("keydown", this.handleKeydown);
6650
+ if (this.returnFocusValue)
6651
+ this.previousFocus?.focus();
6652
+ this.previousFocus = null;
6653
+ }
6654
+ focusInitial() {
6655
+ const target = this.initialFocusValue ? this.element.querySelector(this.initialFocusValue) : this.focusable()[0];
6656
+ target?.focus();
6657
+ }
6658
+ focusable() {
6659
+ return Array.from(
6660
+ this.element.querySelectorAll(FOCUSABLE$1)
6661
+ ).filter((el) => !el.closest("[inert]"));
6662
+ }
6663
+ handleKeydown = (event) => {
6664
+ if (event.key !== "Tab")
6665
+ return;
6666
+ const focusable = this.focusable();
6667
+ if (focusable.length === 0) {
6668
+ event.preventDefault();
6669
+ return;
6670
+ }
6671
+ const first = focusable[0];
6672
+ const last = focusable[focusable.length - 1];
6673
+ const active = document.activeElement;
6674
+ if (event.shiftKey) {
6675
+ if (active === first || !this.element.contains(active)) {
6676
+ event.preventDefault();
6677
+ last.focus();
6678
+ }
6679
+ } else if (active === last || !this.element.contains(active)) {
6680
+ event.preventDefault();
6681
+ first.focus();
6682
+ }
6683
+ };
6684
+ }
6685
+
6686
+ const FOCUSABLE = [
6687
+ "a[href]",
6688
+ "button:not([disabled])",
6689
+ "textarea:not([disabled])",
6690
+ "input:not([disabled])",
6691
+ "select:not([disabled])",
6692
+ '[tabindex]:not([tabindex="-1"])'
6693
+ ].join(", ");
6694
+ const GAP = 12;
6695
+ class Onboarding extends Controller {
6696
+ static targets = ["step", "overlay", "card", "title", "body", "back", "next", "done", "arrow"];
6697
+ static values = {
6698
+ autoOpen: { type: Boolean, default: false },
6699
+ confirm: { type: String, default: "" }
6700
+ };
6701
+ steps = [];
6702
+ index = 0;
6703
+ active = false;
6704
+ previousFocus = null;
6705
+ highlighted = null;
6706
+ connect() {
6707
+ this.steps = this.stepTargets.map((el) => ({
6708
+ el,
6709
+ name: el.dataset.name ?? "",
6710
+ selector: el.dataset.selector ?? "",
6711
+ position: el.dataset.position ?? "auto",
6712
+ title: el.dataset.title ?? "",
6713
+ nextText: el.dataset.nextText ?? "Next",
6714
+ backText: el.dataset.backText ?? "Back",
6715
+ completeText: el.dataset.completeText ?? "Done",
6716
+ canClickTarget: el.dataset.canClickTarget === "true"
6717
+ }));
6718
+ window.addEventListener("midwest-onboarding:open", this.handleOpen);
6719
+ if (this.autoOpenValue)
6720
+ this.start();
6721
+ }
6722
+ disconnect() {
6723
+ window.removeEventListener("midwest-onboarding:open", this.handleOpen);
6724
+ this.teardown();
6725
+ }
6726
+ start() {
6727
+ if (this.active || this.steps.length === 0)
6728
+ return;
6729
+ this.active = true;
6730
+ this.previousFocus = document.activeElement;
6731
+ this.overlayTarget.hidden = false;
6732
+ this.cardTarget.hidden = false;
6733
+ document.addEventListener("keydown", this.handleKeydown, true);
6734
+ window.addEventListener("resize", this.reposition);
6735
+ window.addEventListener("scroll", this.reposition, true);
6736
+ this.dispatch("start", { bubbles: true });
6737
+ this.render(0);
6738
+ }
6739
+ next() {
6740
+ if (this.index < this.steps.length - 1)
6741
+ this.render(this.index + 1);
6742
+ else
6743
+ this.complete();
6744
+ }
6745
+ back() {
6746
+ if (this.index > 0)
6747
+ this.render(this.index - 1);
6748
+ }
6749
+ // Jump to a step by its `name`.
6750
+ show(name) {
6751
+ const target = this.steps.findIndex((step) => step.name === name);
6752
+ if (target >= 0) {
6753
+ if (!this.active)
6754
+ this.start();
6755
+ this.render(target);
6756
+ }
6757
+ }
6758
+ complete() {
6759
+ if (!this.active)
6760
+ return;
6761
+ this.teardown();
6762
+ this.dispatch("complete", { bubbles: true });
6763
+ }
6764
+ cancel() {
6765
+ if (!this.active)
6766
+ return;
6767
+ if (this.confirmValue && !window.confirm(this.confirmValue))
6768
+ return;
6769
+ this.teardown();
6770
+ this.dispatch("cancel", { bubbles: true });
6771
+ }
6772
+ render(index) {
6773
+ this.index = index;
6774
+ const step = this.steps[index];
6775
+ this.clearHighlight();
6776
+ this.titleTarget.textContent = step.title;
6777
+ this.renderBody(step);
6778
+ this.renderButtons(step, index);
6779
+ const target = step.selector ? document.querySelector(step.selector) : null;
6780
+ if (target) {
6781
+ this.highlight(target, step.canClickTarget);
6782
+ this.position(target, step.position);
6783
+ } else {
6784
+ this.center();
6785
+ }
6786
+ this.dispatch("show", { detail: { index, name: step.name }, bubbles: true });
6787
+ this.focusCard();
6788
+ }
6789
+ renderBody(step) {
6790
+ this.bodyTarget.replaceChildren();
6791
+ const template = step.el.querySelector("template");
6792
+ if (template)
6793
+ this.bodyTarget.appendChild(template.content.cloneNode(true));
6794
+ }
6795
+ renderButtons(step, index) {
6796
+ const isFirst = index === 0;
6797
+ const isLast = index === this.steps.length - 1;
6798
+ this.backTarget.textContent = step.backText;
6799
+ this.backTarget.style.visibility = isFirst ? "hidden" : "";
6800
+ this.nextTarget.textContent = step.nextText;
6801
+ this.nextTarget.style.display = isLast ? "none" : "";
6802
+ this.doneTarget.textContent = step.completeText;
6803
+ this.doneTarget.style.display = isLast ? "" : "none";
6804
+ }
6805
+ highlight(target, canClickTarget) {
6806
+ target.classList.add("midwest-onboarding-highlight");
6807
+ target.classList.toggle("is-locked", !canClickTarget);
6808
+ this.highlighted = target;
6809
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
6810
+ }
6811
+ clearHighlight() {
6812
+ this.highlighted?.classList.remove("midwest-onboarding-highlight", "is-locked");
6813
+ this.highlighted = null;
6814
+ }
6815
+ // Places the card against the target, choosing a side when `position` is auto.
6816
+ position(target, position) {
6817
+ const rect = target.getBoundingClientRect();
6818
+ const card = this.cardTarget.getBoundingClientRect();
6819
+ const vw = window.innerWidth;
6820
+ const vh = window.innerHeight;
6821
+ let placement = position;
6822
+ if (placement === "auto") {
6823
+ if (rect.bottom + card.height + GAP <= vh)
6824
+ placement = "bottom";
6825
+ else if (rect.top - card.height - GAP >= 0)
6826
+ placement = "top";
6827
+ else if (rect.right + card.width + GAP <= vw)
6828
+ placement = "right";
6829
+ else
6830
+ placement = "left";
6831
+ }
6832
+ let top;
6833
+ let left;
6834
+ switch (placement) {
6835
+ case "top":
6836
+ top = rect.top - card.height - GAP;
6837
+ left = rect.left + rect.width / 2 - card.width / 2;
6838
+ break;
6839
+ case "left":
6840
+ top = rect.top + rect.height / 2 - card.height / 2;
6841
+ left = rect.left - card.width - GAP;
6842
+ break;
6843
+ case "right":
6844
+ top = rect.top + rect.height / 2 - card.height / 2;
6845
+ left = rect.right + GAP;
6846
+ break;
6847
+ default:
6848
+ top = rect.bottom + GAP;
6849
+ left = rect.left + rect.width / 2 - card.width / 2;
6850
+ break;
6851
+ }
6852
+ left = Math.max(8, Math.min(left, vw - card.width - 8));
6853
+ top = Math.max(8, Math.min(top, vh - card.height - 8));
6854
+ this.cardTarget.style.top = `${top}px`;
6855
+ this.cardTarget.style.left = `${left}px`;
6856
+ this.cardTarget.dataset.placement = placement;
6857
+ this.positionArrow(placement, rect, top, left);
6858
+ }
6859
+ // Nudges the arrow so it points at the target's center, not the card's.
6860
+ positionArrow(placement, rect, top, left) {
6861
+ const card = this.cardTarget.getBoundingClientRect();
6862
+ const arrow = this.arrowTarget.style;
6863
+ if (placement === "top" || placement === "bottom") {
6864
+ const targetCenter = rect.left + rect.width / 2;
6865
+ const offset = Math.max(12, Math.min(targetCenter - left, card.width - 12));
6866
+ arrow.left = `${offset}px`;
6867
+ arrow.top = "";
6868
+ } else {
6869
+ const targetCenter = rect.top + rect.height / 2;
6870
+ const offset = Math.max(12, Math.min(targetCenter - top, card.height - 12));
6871
+ arrow.top = `${offset}px`;
6872
+ arrow.left = "";
6873
+ }
6874
+ }
6875
+ center() {
6876
+ const card = this.cardTarget.getBoundingClientRect();
6877
+ this.cardTarget.style.top = `${Math.max(8, window.innerHeight / 2 - card.height / 2)}px`;
6878
+ this.cardTarget.style.left = `${Math.max(8, window.innerWidth / 2 - card.width / 2)}px`;
6879
+ this.cardTarget.dataset.placement = "center";
6880
+ }
6881
+ focusCard() {
6882
+ requestAnimationFrame(() => {
6883
+ if (this.active)
6884
+ this.cardTarget.focus();
6885
+ });
6886
+ }
6887
+ teardown() {
6888
+ this.active = false;
6889
+ this.clearHighlight();
6890
+ this.overlayTarget.hidden = true;
6891
+ this.cardTarget.hidden = true;
6892
+ document.removeEventListener("keydown", this.handleKeydown, true);
6893
+ window.removeEventListener("resize", this.reposition);
6894
+ window.removeEventListener("scroll", this.reposition, true);
6895
+ this.previousFocus?.focus();
6896
+ this.previousFocus = null;
6897
+ }
6898
+ handleOpen = (event) => {
6899
+ const detail = event.detail ?? {};
6900
+ const id = String(detail.id ?? "").replace("#", "");
6901
+ if (id === this.element.id)
6902
+ this.start();
6903
+ };
6904
+ reposition = () => {
6905
+ if (!this.active)
6906
+ return;
6907
+ const step = this.steps[this.index];
6908
+ const target = step.selector ? document.querySelector(step.selector) : null;
6909
+ if (target)
6910
+ this.position(target, step.position);
6911
+ else
6912
+ this.center();
6913
+ };
6914
+ handleKeydown = (event) => {
6915
+ if (!this.active)
6916
+ return;
6917
+ switch (event.key) {
6918
+ case "Escape":
6919
+ event.preventDefault();
6920
+ this.cancel();
6921
+ break;
6922
+ case "ArrowRight":
6923
+ event.preventDefault();
6924
+ this.next();
6925
+ break;
6926
+ case "ArrowLeft":
6927
+ event.preventDefault();
6928
+ this.back();
6929
+ break;
6930
+ case "Tab":
6931
+ this.trapTab(event);
6932
+ break;
6933
+ }
6934
+ };
6935
+ // Keep Tab focus within the card while the tour is active.
6936
+ trapTab(event) {
6937
+ const focusable = Array.from(this.cardTarget.querySelectorAll(FOCUSABLE)).filter((el) => !el.hidden && el.offsetParent !== null && getComputedStyle(el).visibility !== "hidden");
6938
+ if (focusable.length === 0)
6939
+ return;
6940
+ const first = focusable[0];
6941
+ const last = focusable[focusable.length - 1];
6942
+ const active = document.activeElement;
6943
+ if (event.shiftKey) {
6944
+ if (active === first || active === this.cardTarget || !this.cardTarget.contains(active)) {
6945
+ event.preventDefault();
6946
+ last.focus();
6947
+ }
6948
+ } else if (active === last || !this.cardTarget.contains(active)) {
6949
+ event.preventDefault();
6950
+ first.focus();
6951
+ }
6952
+ }
6953
+ }
6954
+
6955
+ class ThemeSwitch extends Controller {
6956
+ static values = {
6957
+ storageKey: { type: String, default: "midwest-theme" }
6958
+ };
6959
+ connect() {
6960
+ const isDark = this.resolveTheme() === "dark";
6961
+ this.apply(isDark);
6962
+ this.element.checked = isDark;
6963
+ }
6964
+ toggle() {
6965
+ const isDark = this.element.checked;
6966
+ this.apply(isDark);
6967
+ this.persist(isDark ? "dark" : "light");
6968
+ }
6969
+ resolveTheme() {
6970
+ const stored = this.read();
6971
+ if (stored === "dark" || stored === "light")
6972
+ return stored;
6973
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
6974
+ }
6975
+ apply(isDark) {
6976
+ document.documentElement.classList.toggle("dark", isDark);
6977
+ }
6978
+ read() {
6979
+ try {
6980
+ return window.localStorage.getItem(this.storageKeyValue);
6981
+ } catch {
6982
+ return null;
6983
+ }
6984
+ }
6985
+ persist(theme) {
6986
+ try {
6987
+ window.localStorage.setItem(this.storageKeyValue, theme);
6988
+ } catch {
6989
+ }
6990
+ }
6991
+ }
6992
+
6558
6993
  function registerMidwestControllers(application) {
6559
6994
  application.register("midwest-card", Card);
6560
6995
  application.register("midwest-banner", Banner);
@@ -6590,6 +7025,10 @@ function registerMidwestControllers(application) {
6590
7025
  application.register("midwest-code-block", CodeBlock);
6591
7026
  application.register("midwest-date-picker", DatePicker);
6592
7027
  application.register("midwest-time-picker", TimePicker);
7028
+ application.register("midwest-intersection-observer", ViewportObserver);
7029
+ application.register("midwest-focus-trap", FocusTrap);
7030
+ application.register("midwest-onboarding", Onboarding);
7031
+ application.register("midwest-theme-switch", ThemeSwitch);
6593
7032
  }
6594
7033
 
6595
7034
  export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };