baldur 0.1.1

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 (164) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +3 -0
  3. data/LICENSE +21 -0
  4. data/README.md +318 -0
  5. data/TODO.md +6 -0
  6. data/app/assets/javascripts/baldur/controllers/accordion_controller.js +148 -0
  7. data/app/assets/javascripts/baldur/controllers/alert_controller.js +209 -0
  8. data/app/assets/javascripts/baldur/controllers/date_field_controller.js +558 -0
  9. data/app/assets/javascripts/baldur/controllers/details_menu_controller.js +30 -0
  10. data/app/assets/javascripts/baldur/controllers/form_submit_controller.js +7 -0
  11. data/app/assets/javascripts/baldur/controllers/marketing_pricing_controller.js +47 -0
  12. data/app/assets/javascripts/baldur/controllers/marketing_tabs_controller.js +118 -0
  13. data/app/assets/javascripts/baldur/controllers/menu_select_controller.js +401 -0
  14. data/app/assets/javascripts/baldur/controllers/mobile_sidebar_controller.js +13 -0
  15. data/app/assets/javascripts/baldur/controllers/modal_controller.js +149 -0
  16. data/app/assets/javascripts/baldur/controllers/panel_right_controller.js +1 -0
  17. data/app/assets/javascripts/baldur/controllers/panel_secondary_controller.js +129 -0
  18. data/app/assets/javascripts/baldur/controllers/segmented_tabs_controller.js +38 -0
  19. data/app/assets/javascripts/baldur/controllers/sidebar_controller.js +77 -0
  20. data/app/assets/javascripts/baldur/controllers/smooth_scroll_controller.js +29 -0
  21. data/app/assets/javascripts/baldur/controllers/snackbar_controller.js +158 -0
  22. data/app/assets/javascripts/baldur/controllers/table_disclosure_controller.js +46 -0
  23. data/app/assets/javascripts/baldur/controllers/theme_controller.js +90 -0
  24. data/app/assets/javascripts/baldur/controllers/tooltip_controller.js +136 -0
  25. data/app/assets/javascripts/baldur/lib/animation-helpers.js +56 -0
  26. data/app/assets/javascripts/baldur/lib/dom-helpers.js +80 -0
  27. data/app/assets/javascripts/baldur/lib/field-validation-helpers.js +36 -0
  28. data/app/assets/javascripts/baldur/lib/focus-management.js +89 -0
  29. data/app/assets/javascripts/baldur/lib/formatting-helpers.js +100 -0
  30. data/app/assets/javascripts/baldur/lib/lucide.js +20 -0
  31. data/app/assets/javascripts/baldur/lib/snackbar.js +50 -0
  32. data/app/assets/javascripts/baldur/lib/storage-helpers.js +50 -0
  33. data/app/assets/stylesheets/baldur/application/components/alert.css +226 -0
  34. data/app/assets/stylesheets/baldur/application/components/app_bar.css +41 -0
  35. data/app/assets/stylesheets/baldur/application/components/button.css +173 -0
  36. data/app/assets/stylesheets/baldur/application/components/card.css +63 -0
  37. data/app/assets/stylesheets/baldur/application/components/chart.css +40 -0
  38. data/app/assets/stylesheets/baldur/application/components/chip.css +51 -0
  39. data/app/assets/stylesheets/baldur/application/components/dialog.css +81 -0
  40. data/app/assets/stylesheets/baldur/application/components/forms.css +624 -0
  41. data/app/assets/stylesheets/baldur/application/components/layout.css +2 -0
  42. data/app/assets/stylesheets/baldur/application/components/list.css +15 -0
  43. data/app/assets/stylesheets/baldur/application/components/menu.css +300 -0
  44. data/app/assets/stylesheets/baldur/application/components/panel-right.css +1 -0
  45. data/app/assets/stylesheets/baldur/application/components/panel-secondary.css +71 -0
  46. data/app/assets/stylesheets/baldur/application/components/progress.css +84 -0
  47. data/app/assets/stylesheets/baldur/application/components/segmented-buttons.css +117 -0
  48. data/app/assets/stylesheets/baldur/application/components/settings-nav.css +84 -0
  49. data/app/assets/stylesheets/baldur/application/components/sidebar.css +123 -0
  50. data/app/assets/stylesheets/baldur/application/components/snackbar.css +179 -0
  51. data/app/assets/stylesheets/baldur/application/components/stepper.css +124 -0
  52. data/app/assets/stylesheets/baldur/application/components/switch.css +105 -0
  53. data/app/assets/stylesheets/baldur/application/components/table.css +331 -0
  54. data/app/assets/stylesheets/baldur/application/components/timeline.css +184 -0
  55. data/app/assets/stylesheets/baldur/application/components/utilities.css +180 -0
  56. data/app/assets/stylesheets/baldur/application/global.css +125 -0
  57. data/app/assets/stylesheets/baldur/application/marketing/layout.css +36 -0
  58. data/app/assets/stylesheets/baldur/application/motion.css +125 -0
  59. data/app/assets/stylesheets/baldur/application/theme.css +329 -0
  60. data/app/assets/stylesheets/baldur/theme/dark.css +90 -0
  61. data/app/assets/stylesheets/baldur/theme/light.css +82 -0
  62. data/app/assets/stylesheets/baldur.css +27 -0
  63. data/app/assets/stylesheets/baldur_panel_right.css +1 -0
  64. data/app/assets/stylesheets/baldur_panel_secondary.css +1 -0
  65. data/app/assets/tailwind/baldur/engine.css +5 -0
  66. data/app/helpers/baldur/compatibility/ui_aliases.rb +7 -0
  67. data/app/helpers/baldur/marketing_helper.rb +121 -0
  68. data/app/helpers/baldur/optional/auth_page_helper.rb +17 -0
  69. data/app/helpers/baldur/optional/google_auth_helper.rb +16 -0
  70. data/app/helpers/baldur/optional/panel_right_helper.rb +7 -0
  71. data/app/helpers/baldur/optional/panel_secondary_helper.rb +26 -0
  72. data/app/helpers/baldur/render_helper.rb +13 -0
  73. data/app/helpers/baldur/ui_helper.rb +217 -0
  74. data/app/helpers/baldur/ui_helper_feedback.rb +93 -0
  75. data/app/helpers/baldur/ui_helper_forms.rb +230 -0
  76. data/app/helpers/baldur/ui_helper_unavailable.rb +98 -0
  77. data/app/views/baldur/components/_accordion.html.erb +30 -0
  78. data/app/views/baldur/components/_action_row.html.erb +6 -0
  79. data/app/views/baldur/components/_alert.html.erb +61 -0
  80. data/app/views/baldur/components/_badge.html.erb +25 -0
  81. data/app/views/baldur/components/_button.html.erb +81 -0
  82. data/app/views/baldur/components/_card.html.erb +40 -0
  83. data/app/views/baldur/components/_chart_card.html.erb +42 -0
  84. data/app/views/baldur/components/_checkbox.html.erb +27 -0
  85. data/app/views/baldur/components/_date_field.html.erb +43 -0
  86. data/app/views/baldur/components/_google_sign_in_button.html.erb +1 -0
  87. data/app/views/baldur/components/_kebab_menu.html.erb +36 -0
  88. data/app/views/baldur/components/_kpi.html.erb +45 -0
  89. data/app/views/baldur/components/_menu_select.html.erb +78 -0
  90. data/app/views/baldur/components/_modal.html.erb +54 -0
  91. data/app/views/baldur/components/_pagination.html.erb +61 -0
  92. data/app/views/baldur/components/_segmented_buttons.html.erb +51 -0
  93. data/app/views/baldur/components/_settings_nav.html.erb +41 -0
  94. data/app/views/baldur/components/_snackbar.html.erb +42 -0
  95. data/app/views/baldur/components/_snackbar_stack.html.erb +13 -0
  96. data/app/views/baldur/components/_stepper.html.erb +39 -0
  97. data/app/views/baldur/components/_table.html.erb +117 -0
  98. data/app/views/baldur/components/_table_card.html.erb +86 -0
  99. data/app/views/baldur/components/_table_footer.html.erb +68 -0
  100. data/app/views/baldur/components/_text_field.html.erb +33 -0
  101. data/app/views/baldur/components/_tooltip.html.erb +73 -0
  102. data/app/views/baldur/marketing/_cta_banner.html.erb +20 -0
  103. data/app/views/baldur/marketing/_faq_section.html.erb +37 -0
  104. data/app/views/baldur/marketing/_features_section.html.erb +67 -0
  105. data/app/views/baldur/marketing/_footer.html.erb +38 -0
  106. data/app/views/baldur/marketing/_hero_section.html.erb +259 -0
  107. data/app/views/baldur/marketing/_pricing_tables.html.erb +99 -0
  108. data/app/views/baldur/marketing/_testimonials_section.html.erb +80 -0
  109. data/app/views/baldur/marketing/_top_nav.html.erb +28 -0
  110. data/app/views/baldur/optional/_auth_page.html.erb +21 -0
  111. data/app/views/baldur/optional/_google_sign_in_button.html.erb +19 -0
  112. data/app/views/baldur/optional/_panel_right.html.erb +1 -0
  113. data/app/views/baldur/optional/_panel_secondary.html.erb +34 -0
  114. data/baldur.gemspec +30 -0
  115. data/config/importmap.rb +2 -0
  116. data/lib/baldur/configuration.rb +24 -0
  117. data/lib/baldur/engine.rb +10 -0
  118. data/lib/baldur/version.rb +3 -0
  119. data/lib/baldur.rb +17 -0
  120. data/lib/generators/baldur/install/install_generator.rb +113 -0
  121. data/lib/generators/baldur/install/templates/baldur_initializer.rb +19 -0
  122. data/lib/generators/baldur/install/templates/fonts.css +14 -0
  123. data/lib/generators/baldur/install/templates/theme.css +27 -0
  124. data/lib/generators/baldur/install/templates/ui_helper.rb +4 -0
  125. data/lib/generators/baldur/install_google_auth/install_google_auth_generator.rb +15 -0
  126. data/lib/generators/baldur/install_panel_right/install_panel_right_generator.rb +9 -0
  127. data/lib/generators/baldur/install_panel_secondary/install_panel_secondary_generator.rb +21 -0
  128. data/script/verify_host_install +111 -0
  129. data/test/gemspec_test.rb +11 -0
  130. data/test/install_generator_test.rb +35 -0
  131. data/test/install_panel_secondary_generator_test.rb +21 -0
  132. data/test/marketing_helper_test.rb +38 -0
  133. data/test/run_all.rb +3 -0
  134. data/test/test_helper.rb +9 -0
  135. data/test/tmp/install_generator/app/assets/stylesheets/fonts.css +14 -0
  136. data/test/tmp/install_generator/app/assets/stylesheets/theme.css +27 -0
  137. data/test/tmp/install_generator/app/assets/tailwind/application.css +4 -0
  138. data/test/tmp/install_generator/app/helpers/ui_helper.rb +4 -0
  139. data/test/tmp/install_generator/app/javascript/controllers/accordion_controller.js +1 -0
  140. data/test/tmp/install_generator/app/javascript/controllers/date_field_controller.js +1 -0
  141. data/test/tmp/install_generator/app/javascript/controllers/details_menu_controller.js +1 -0
  142. data/test/tmp/install_generator/app/javascript/controllers/form_submit_controller.js +1 -0
  143. data/test/tmp/install_generator/app/javascript/controllers/marketing_pricing_controller.js +1 -0
  144. data/test/tmp/install_generator/app/javascript/controllers/marketing_tabs_controller.js +1 -0
  145. data/test/tmp/install_generator/app/javascript/controllers/menu_select_controller.js +1 -0
  146. data/test/tmp/install_generator/app/javascript/controllers/modal_controller.js +1 -0
  147. data/test/tmp/install_generator/app/javascript/controllers/segmented_tabs_controller.js +1 -0
  148. data/test/tmp/install_generator/app/javascript/controllers/sidebar_controller.js +1 -0
  149. data/test/tmp/install_generator/app/javascript/controllers/smooth_scroll_controller.js +1 -0
  150. data/test/tmp/install_generator/app/javascript/controllers/snackbar_controller.js +1 -0
  151. data/test/tmp/install_generator/app/javascript/controllers/theme_controller.js +1 -0
  152. data/test/tmp/install_generator/app/javascript/controllers/tooltip_controller.js +1 -0
  153. data/test/tmp/install_generator/app/javascript/lib/animation-helpers.js +1 -0
  154. data/test/tmp/install_generator/app/javascript/lib/dom-helpers.js +1 -0
  155. data/test/tmp/install_generator/app/javascript/lib/field-validation-helpers.js +1 -0
  156. data/test/tmp/install_generator/app/javascript/lib/focus-management.js +1 -0
  157. data/test/tmp/install_generator/app/javascript/lib/formatting-helpers.js +1 -0
  158. data/test/tmp/install_generator/app/javascript/lib/snackbar.js +1 -0
  159. data/test/tmp/install_generator/app/javascript/lib/storage-helpers.js +1 -0
  160. data/test/tmp/install_generator/config/initializers/baldur.rb +19 -0
  161. data/test/tmp/install_panel_secondary_generator/app/assets/tailwind/application.css +2 -0
  162. data/test/tmp/install_panel_secondary_generator/app/helpers/panel_secondary_helper.rb +3 -0
  163. data/test/tmp/install_panel_secondary_generator/app/javascript/controllers/panel_secondary_controller.js +1 -0
  164. metadata +259 -0
@@ -0,0 +1,129 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel", "trigger"];
5
+ static values = {
6
+ pushSelector: { type: String, default: "main" }
7
+ };
8
+
9
+ connect() {
10
+ this.element.__panelSecondaryController = this;
11
+ this.externalTriggerBindings = [];
12
+ this.panelSelector = this.hasPanelTarget && this.panelTarget.id ? `#${this.panelTarget.id}` : null;
13
+ this.isOpen = this.panelTarget?.classList.contains("is-open") || this.panelTarget?.getAttribute("aria-hidden") === "false";
14
+ this.setupExternalTriggers();
15
+ this.syncState(this.isOpen);
16
+ }
17
+
18
+ disconnect() {
19
+ if (this.element.__panelSecondaryController === this) {
20
+ delete this.element.__panelSecondaryController;
21
+ }
22
+
23
+ this.teardownExternalTriggers();
24
+ this.syncPushSurface(false);
25
+ }
26
+
27
+ toggle() {
28
+ this.isOpen ? this.close() : this.open();
29
+ }
30
+
31
+ open(detail = this.defaultDetail()) {
32
+ if (!this.hasPanelTarget) return;
33
+
34
+ this.isOpen = true;
35
+ this.syncState(true);
36
+ this.dispatchOpenedHooks(detail);
37
+ }
38
+
39
+ close() {
40
+ if (!this.hasPanelTarget) return;
41
+
42
+ this.isOpen = false;
43
+ this.syncState(false);
44
+ this.dispatchClosedHooks();
45
+ }
46
+
47
+ syncState(open) {
48
+ this.panelTarget.classList.toggle("is-open", open);
49
+ this.panelTarget.setAttribute("aria-hidden", String(!open));
50
+ this.syncTrigger(open);
51
+ this.syncPushSurface(open);
52
+ }
53
+
54
+ syncTrigger(open) {
55
+ if (!this.hasTriggerTarget) return;
56
+ this.triggerTarget.setAttribute("aria-expanded", String(open));
57
+ }
58
+
59
+ syncPushSurface(open) {
60
+ const target = document.querySelector(this.pushSelectorValue);
61
+ if (!target || !this.hasPanelTarget) return;
62
+
63
+ target.style.paddingRight = open ? `${this.panelTarget.offsetWidth}px` : "";
64
+ }
65
+
66
+ setupExternalTriggers() {
67
+ if (!this.panelSelector) return;
68
+
69
+ const triggers = document.querySelectorAll(`[data-open-panel="${this.panelSelector}"]`);
70
+ triggers.forEach((trigger) => {
71
+ const handler = (event) => {
72
+ event.preventDefault();
73
+ this.previouslyFocusedElement = trigger;
74
+ this.open(this.detailFromTrigger(trigger));
75
+ };
76
+
77
+ trigger.addEventListener("click", handler);
78
+ this.externalTriggerBindings.push({ trigger, handler });
79
+ });
80
+ }
81
+
82
+ teardownExternalTriggers() {
83
+ this.externalTriggerBindings.forEach(({ trigger, handler }) => {
84
+ trigger.removeEventListener("click", handler);
85
+ });
86
+ this.externalTriggerBindings = [];
87
+ }
88
+
89
+ detailFromTrigger(trigger) {
90
+ return {
91
+ ...this.defaultDetail(),
92
+ trigger,
93
+ payload: this.parsePayload(trigger?.dataset.panelPayload)
94
+ };
95
+ }
96
+
97
+ defaultDetail() {
98
+ return {
99
+ id: this.hasPanelTarget ? this.panelTarget.id : null,
100
+ selector: this.panelSelector,
101
+ trigger: null,
102
+ payload: null
103
+ };
104
+ }
105
+
106
+ parsePayload(raw) {
107
+ if (!raw) return null;
108
+
109
+ try {
110
+ return JSON.parse(raw);
111
+ } catch (_error) {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ dispatchOpenedHooks(detail) {
117
+ const payload = detail || this.defaultDetail();
118
+
119
+ this.element.dispatchEvent(new CustomEvent("baldur:panel:opened", { bubbles: true, detail: payload }));
120
+ window.dispatchEvent(new CustomEvent("baldur:panel:opened", { detail: payload }));
121
+ }
122
+
123
+ dispatchClosedHooks() {
124
+ const detail = this.defaultDetail();
125
+
126
+ this.element.dispatchEvent(new CustomEvent("baldur:panel:closed", { bubbles: true, detail }));
127
+ window.dispatchEvent(new CustomEvent("baldur:panel:closed", { detail }));
128
+ }
129
+ }
@@ -0,0 +1,38 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ['tab', 'panel']
5
+ static values = { active: String }
6
+
7
+ connect() {
8
+ const initial = this.activeValue || this.tabTargets[0]?.dataset.tabValue
9
+ if (initial) {
10
+ this.show(initial)
11
+ }
12
+ }
13
+
14
+ select(event) {
15
+ event.preventDefault()
16
+ const value = event.currentTarget.dataset.tabValue
17
+ if (value) {
18
+ this.show(value)
19
+ }
20
+ }
21
+
22
+ show(value) {
23
+ this.tabTargets.forEach((tab) => {
24
+ const selected = tab.dataset.tabValue === value
25
+ tab.classList.toggle('is-selected', selected)
26
+ tab.setAttribute('aria-selected', selected)
27
+ tab.tabIndex = selected ? 0 : -1
28
+ })
29
+
30
+ this.panelTargets.forEach((panel) => {
31
+ const selected = panel.dataset.tabValue === value
32
+ panel.classList.toggle('hidden', !selected)
33
+ panel.setAttribute('aria-hidden', (!selected).toString())
34
+ })
35
+
36
+ this.activeValue = value
37
+ }
38
+ }
@@ -0,0 +1,77 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { iconSvg } from "baldur/lib/lucide";
3
+
4
+ export default class extends Controller {
5
+ static targets = ["toggleButton", "toggleIcon"]
6
+ static values = {
7
+ collapsed: Boolean,
8
+ storageKey: { type: String, default: "baldur-sidebar-collapsed" }
9
+ }
10
+
11
+ initialize() {
12
+ this._loadingStoredState = true
13
+ }
14
+
15
+ connect() {
16
+ const stored = this.readStoredValue()
17
+ if (stored !== null) {
18
+ this.collapsedValue = stored === "true"
19
+ }
20
+
21
+ this._loadingStoredState = false
22
+ this.syncUI()
23
+ }
24
+
25
+ toggle() {
26
+ this.collapsedValue = !this.collapsedValue
27
+ }
28
+
29
+ collapsedValueChanged() {
30
+ if (this._loadingStoredState) {
31
+ return
32
+ }
33
+
34
+ const value = String(this.collapsedValue)
35
+ this.writeStoredValue(value)
36
+ this.syncUI()
37
+ }
38
+
39
+ readStoredValue() {
40
+ const key = this.storageKeyValue
41
+
42
+ try {
43
+ const value = window.localStorage.getItem(key)
44
+ if (value !== null) return value
45
+ } catch {
46
+ // localStorage may be disabled
47
+ }
48
+
49
+ const match = document.cookie.match(new RegExp(`(?:^|; )${encodeURIComponent(key)}=([^;]*)`))
50
+ return match ? decodeURIComponent(match[1]) : null
51
+ }
52
+
53
+ writeStoredValue(value) {
54
+ const key = this.storageKeyValue
55
+
56
+ try {
57
+ window.localStorage.setItem(key, value)
58
+ } catch {
59
+ // localStorage may be disabled
60
+ }
61
+
62
+ document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent(value)}; path=/; max-age=31536000; samesite=lax`
63
+ }
64
+
65
+ syncUI() {
66
+ this.element.dataset.sidebarCollapsedValue = String(this.collapsedValue)
67
+
68
+ if (this.hasToggleIconTarget) {
69
+ const iconName = this.collapsedValue ? "chevron-right" : "chevron-left"
70
+ this.toggleIconTarget.innerHTML = iconSvg(iconName, "h-5 w-5")
71
+ }
72
+
73
+ if (this.hasToggleButtonTarget) {
74
+ this.toggleButtonTarget.setAttribute("aria-expanded", String(!this.collapsedValue))
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,29 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.onClick = this.handleClick.bind(this);
6
+ this.element.addEventListener("click", this.onClick);
7
+ }
8
+
9
+ disconnect() {
10
+ this.element.removeEventListener("click", this.onClick);
11
+ }
12
+
13
+ handleClick(event) {
14
+ const link = event.target.closest("a[href]");
15
+ if (!link) return;
16
+
17
+ const href = link.getAttribute("href");
18
+ if (!href || !href.startsWith("#") || href.length <= 1) return;
19
+ if (link.getAttribute("download") !== null) return;
20
+
21
+ const target = document.getElementById(href.slice(1));
22
+ if (!target) return;
23
+
24
+ event.preventDefault();
25
+ const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
26
+ target.scrollIntoView({ behavior: prefersReducedMotion ? "auto" : "smooth", block: "start" });
27
+ history.replaceState(null, "", href);
28
+ }
29
+ }
@@ -0,0 +1,158 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { addClass, removeClass } from "baldur/lib/dom-helpers";
3
+
4
+ export default class SnackbarController extends Controller {
5
+ static targets = ["progressIndicator"];
6
+
7
+ static values = {
8
+ snackbarTimeout: { type: Number, default: 6000 }
9
+ };
10
+
11
+ connect() {
12
+ this.totalDuration = Math.max(this.snackbarTimeoutValue, 0);
13
+ this.remainingDuration = this.totalDuration;
14
+ this.isPaused = false;
15
+ this.isHovered = false;
16
+ this.hasFocusWithin = false;
17
+ this.renderProgress(1);
18
+
19
+ requestAnimationFrame(() => {
20
+ addClass(this.element, "motion-layer-enter");
21
+ addClass(this.element, "is-visible");
22
+ this.resumeAutoHide();
23
+ });
24
+
25
+ this.element.addEventListener("animationend", this.handleEntryAnimationEnd);
26
+ this.element.addEventListener("mouseenter", this.handleMouseEnter);
27
+ this.element.addEventListener("mouseleave", this.handleMouseLeave);
28
+ this.element.addEventListener("focusin", this.handleFocusIn);
29
+ this.element.addEventListener("focusout", this.handleFocusOut);
30
+ }
31
+
32
+ disconnect() {
33
+ this.stopCountdown();
34
+ this.element.removeEventListener("animationend", this.handleEntryAnimationEnd);
35
+ this.element.removeEventListener("mouseenter", this.handleMouseEnter);
36
+ this.element.removeEventListener("mouseleave", this.handleMouseLeave);
37
+ this.element.removeEventListener("focusin", this.handleFocusIn);
38
+ this.element.removeEventListener("focusout", this.handleFocusOut);
39
+ }
40
+
41
+ scheduleAutoHide(duration) {
42
+ clearTimeout(this.autoHideTimeout);
43
+ this.autoHideTimeout = setTimeout(() => {
44
+ this.dismiss();
45
+ }, duration);
46
+ }
47
+
48
+ renderProgress(progress) {
49
+ if (!this.hasProgressIndicatorTarget) return;
50
+
51
+ const clamped = Math.min(Math.max(progress, 0), 1);
52
+ this.progressIndicatorTarget.style.strokeDashoffset = String((1 - clamped) * 100);
53
+ }
54
+
55
+ stopCountdown() {
56
+ clearTimeout(this.autoHideTimeout);
57
+ this.autoHideTimeout = null;
58
+
59
+ if (this.rafId) {
60
+ cancelAnimationFrame(this.rafId);
61
+ this.rafId = null;
62
+ }
63
+ }
64
+
65
+ pauseAutoHide() {
66
+ if (this.isPaused || this.element.dataset.dismissed === "true") return;
67
+
68
+ this.isPaused = true;
69
+ if (this.totalDuration <= 0 || !this.startedAt) return;
70
+
71
+ const elapsed = performance.now() - this.startedAt;
72
+ this.remainingDuration = Math.max(this.remainingDuration - elapsed, 0);
73
+ this.stopCountdown();
74
+ this.renderProgress(this.totalDuration > 0 ? this.remainingDuration / this.totalDuration : 0);
75
+ }
76
+
77
+ resumeAutoHide() {
78
+ if (this.element.dataset.dismissed === "true") return;
79
+ if (this.isHovered || this.hasFocusWithin) return;
80
+
81
+ if (this.remainingDuration <= 0 || this.totalDuration <= 0) {
82
+ this.renderProgress(0);
83
+ this.dismiss();
84
+ return;
85
+ }
86
+
87
+ if (!this.isPaused && this.autoHideTimeout) return;
88
+
89
+ this.isPaused = false;
90
+ this.startedAt = performance.now();
91
+ this.scheduleAutoHide(this.remainingDuration);
92
+ this.rafId = requestAnimationFrame(this.updateProgress);
93
+ }
94
+
95
+ dismiss() {
96
+ if (this.element.dataset.dismissed === "true") return;
97
+
98
+ this.element.dataset.dismissed = "true";
99
+ this.stopCountdown();
100
+ this.renderProgress(0);
101
+ addClass(this.element, "is-leaving");
102
+
103
+ this.element.addEventListener(
104
+ "transitionend",
105
+ () => {
106
+ this.element.remove();
107
+ },
108
+ { once: true }
109
+ );
110
+ }
111
+
112
+ updateProgress = (timestamp) => {
113
+ if (this.isPaused || this.element.dataset.dismissed === "true") return;
114
+
115
+ const elapsed = timestamp - this.startedAt;
116
+ const remaining = Math.max(this.remainingDuration - elapsed, 0);
117
+ this.renderProgress(this.totalDuration > 0 ? remaining / this.totalDuration : 0);
118
+
119
+ if (remaining > 0) {
120
+ this.rafId = requestAnimationFrame(this.updateProgress);
121
+ } else {
122
+ this.rafId = null;
123
+ }
124
+ };
125
+
126
+ handleEntryAnimationEnd = (event) => {
127
+ if (event.animationName !== "layer-enter") return;
128
+ removeClass(this.element, "motion-layer-enter");
129
+ };
130
+
131
+ handleMouseEnter = () => {
132
+ this.isHovered = true;
133
+ this.pauseAutoHide();
134
+ };
135
+
136
+ handleMouseLeave = () => {
137
+ this.isHovered = false;
138
+ this.resumeAutoHide();
139
+ };
140
+
141
+ handleFocusIn = () => {
142
+ this.hasFocusWithin = true;
143
+ this.pauseAutoHide();
144
+ };
145
+
146
+ handleFocusOut = (event) => {
147
+ if (this.element.contains(event.relatedTarget)) return;
148
+
149
+ this.hasFocusWithin = false;
150
+ this.resumeAutoHide();
151
+ };
152
+
153
+ // Allow manual dismissal via button
154
+ handleDismiss(event) {
155
+ event?.preventDefault?.();
156
+ this.dismiss();
157
+ }
158
+ }
@@ -0,0 +1,46 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["content", "trigger"];
5
+
6
+ connect() {
7
+ this.open = false;
8
+ this.contentTarget.style.maxHeight = "0px";
9
+ this.triggerTarget.setAttribute("aria-expanded", "false");
10
+ }
11
+
12
+ toggle(event) {
13
+ event.preventDefault();
14
+
15
+ if (this.open) {
16
+ this.close();
17
+ return;
18
+ }
19
+
20
+ this.openDisclosure();
21
+ }
22
+
23
+ openDisclosure() {
24
+ const content = this.contentTarget;
25
+ this.open = true;
26
+ this.element.classList.add("is-open");
27
+ this.triggerTarget.setAttribute("aria-expanded", "true");
28
+ content.style.maxHeight = "0px";
29
+
30
+ requestAnimationFrame(() => {
31
+ content.style.maxHeight = `${content.scrollHeight}px`;
32
+ });
33
+ }
34
+
35
+ close() {
36
+ const content = this.contentTarget;
37
+ this.open = false;
38
+ this.element.classList.remove("is-open");
39
+ this.triggerTarget.setAttribute("aria-expanded", "false");
40
+ content.style.maxHeight = `${content.scrollHeight}px`;
41
+
42
+ requestAnimationFrame(() => {
43
+ content.style.maxHeight = "0px";
44
+ });
45
+ }
46
+ }
@@ -0,0 +1,90 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { getFromStorage, saveToStorage, getSystemPreference } from "baldur/lib/storage-helpers";
3
+ import { addClass, removeClass, toggleClass, setAttrs } from "baldur/lib/dom-helpers";
4
+
5
+ export default class ThemeController extends Controller {
6
+ static targets = ["toggle"];
7
+ static values = {
8
+ storageKey: { type: String, default: "baldur.theme" },
9
+ themes: { type: Array, default: ["light", "dark"] }
10
+ };
11
+
12
+ connect() {
13
+ this.initializeTheme();
14
+ this.attachEventListeners();
15
+ }
16
+
17
+ initializeTheme() {
18
+ const theme = this.getCurrentTheme();
19
+ this.applyTheme(theme, false);
20
+ this.syncToggles(theme);
21
+ }
22
+
23
+ attachEventListeners() {
24
+ this.toggleTargets.forEach((toggle) => {
25
+ toggle.addEventListener("change", () => this.handleToggleChange(toggle));
26
+ });
27
+ }
28
+
29
+ getCurrentTheme() {
30
+ // 1. Check localStorage
31
+ const stored = getFromStorage(this.storageKeyValue);
32
+ if (stored && this.themesValue.includes(stored)) return stored;
33
+
34
+ // 2. Default to light when user has not chosen a theme.
35
+ return "light";
36
+ }
37
+
38
+ handleToggleChange(toggle) {
39
+ const newTheme = toggle.checked ? "dark" : "light";
40
+ this.applyTheme(newTheme, true);
41
+ saveToStorage(this.storageKeyValue, newTheme);
42
+ this.syncToggles(newTheme);
43
+ }
44
+
45
+ toggle(event) {
46
+ event?.preventDefault();
47
+
48
+ const newTheme = this.getTheme() === "dark" ? "light" : "dark";
49
+ this.applyTheme(newTheme, true);
50
+ saveToStorage(this.storageKeyValue, newTheme);
51
+ this.syncToggles(newTheme);
52
+ }
53
+
54
+ applyTheme(theme, animate = false) {
55
+ const root = document.documentElement;
56
+
57
+ if (animate) {
58
+ addClass(root, "theme-transition");
59
+ setTimeout(() => removeClass(root, "theme-transition"), 800);
60
+ }
61
+
62
+ // Remove all theme classes
63
+ this.themesValue.forEach((cls) => removeClass(root, cls));
64
+
65
+ // Add new theme class
66
+ addClass(root, theme);
67
+ setAttrs(root, { "data-theme": theme });
68
+
69
+ // Update global state
70
+ window.__baldurThemeState = theme;
71
+ }
72
+
73
+ syncToggles(theme) {
74
+ this.toggleTargets.forEach((toggle) => {
75
+ toggle.checked = theme === "dark";
76
+ });
77
+ }
78
+
79
+ // Public API for external access
80
+ getTheme() {
81
+ return window.__baldurThemeState || this.getCurrentTheme();
82
+ }
83
+
84
+ setTheme(theme, options = {}) {
85
+ const { animate = true } = options;
86
+ this.applyTheme(theme, animate);
87
+ saveToStorage(this.storageKeyValue, theme);
88
+ this.syncToggles(theme);
89
+ }
90
+ }
@@ -0,0 +1,136 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["trigger", "bubble"];
5
+
6
+ connect() {
7
+ this.handleDocumentClick = this.handleDocumentClick.bind(this);
8
+ this.handleDocumentKeydown = this.handleDocumentKeydown.bind(this);
9
+ this.handleViewportResize = this.handleViewportResize.bind(this);
10
+
11
+ document.addEventListener("click", this.handleDocumentClick);
12
+ document.addEventListener("keydown", this.handleDocumentKeydown);
13
+ window.addEventListener("resize", this.handleViewportResize);
14
+
15
+ this.close();
16
+ }
17
+
18
+ disconnect() {
19
+ document.removeEventListener("click", this.handleDocumentClick);
20
+ document.removeEventListener("keydown", this.handleDocumentKeydown);
21
+ window.removeEventListener("resize", this.handleViewportResize);
22
+ }
23
+
24
+ toggle(event) {
25
+ event.preventDefault();
26
+ event.stopPropagation();
27
+
28
+ if (this.isOpen()) {
29
+ this.close();
30
+ return;
31
+ }
32
+
33
+ this.open();
34
+ }
35
+
36
+ open(event) {
37
+ if (event?.type === "focusout" && this.element.contains(event.relatedTarget)) return;
38
+ if (!this.hasBubbleTarget) return;
39
+
40
+ this.bubbleTarget.dataset.state = "open";
41
+ this.bubbleTarget.setAttribute("aria-hidden", "false");
42
+ requestAnimationFrame(() => {
43
+ if (this.isOpen()) this.applyBestPlacement();
44
+ });
45
+
46
+ if (this.hasTriggerTarget) {
47
+ this.triggerTarget.setAttribute("aria-expanded", "true");
48
+ }
49
+ }
50
+
51
+ close(event) {
52
+ if (event?.type === "focusout" && this.element.contains(event.relatedTarget)) return;
53
+ if (!this.hasBubbleTarget) return;
54
+
55
+ this.bubbleTarget.dataset.state = "closed";
56
+ this.bubbleTarget.setAttribute("aria-hidden", "true");
57
+ this.resetPlacement();
58
+
59
+ if (this.hasTriggerTarget) {
60
+ this.triggerTarget.setAttribute("aria-expanded", "false");
61
+ }
62
+ }
63
+
64
+ isOpen() {
65
+ if (!this.hasBubbleTarget) return false;
66
+ return this.bubbleTarget.dataset.state === "open";
67
+ }
68
+
69
+ handleDocumentClick(event) {
70
+ if (!this.element.contains(event.target)) {
71
+ this.close();
72
+ }
73
+ }
74
+
75
+ handleDocumentKeydown(event) {
76
+ if (event.key === "Escape") {
77
+ this.close();
78
+ }
79
+ }
80
+
81
+ handleViewportResize() {
82
+ if (this.isOpen()) {
83
+ this.applyBestPlacement();
84
+ }
85
+ }
86
+
87
+ applyBestPlacement() {
88
+ if (!this.hasBubbleTarget) return;
89
+
90
+ const bubble = this.bubbleTarget;
91
+ const viewportPadding = 8;
92
+ const maxWidth = Math.min(320, Math.max(180, window.innerWidth - viewportPadding * 2));
93
+
94
+ this.resetPlacement();
95
+ bubble.style.maxWidth = `${maxWidth}px`;
96
+
97
+ let rect = bubble.getBoundingClientRect();
98
+
99
+ if (rect.right > window.innerWidth - viewportPadding) {
100
+ bubble.classList.remove("left-0");
101
+ bubble.classList.add("right-0");
102
+ rect = bubble.getBoundingClientRect();
103
+ }
104
+
105
+ if (rect.left < viewportPadding) {
106
+ const shiftRight = viewportPadding - rect.left;
107
+ bubble.style.transform = `translateX(${shiftRight}px)`;
108
+ rect = bubble.getBoundingClientRect();
109
+ } else if (rect.right > window.innerWidth - viewportPadding) {
110
+ const shiftLeft = (window.innerWidth - viewportPadding) - rect.right;
111
+ bubble.style.transform = `translateX(${shiftLeft}px)`;
112
+ rect = bubble.getBoundingClientRect();
113
+ }
114
+
115
+ if (rect.bottom > window.innerHeight - viewportPadding) {
116
+ bubble.classList.remove("top-full", "mt-2");
117
+ bubble.classList.add("bottom-full", "mb-2");
118
+ rect = bubble.getBoundingClientRect();
119
+
120
+ if (rect.top < viewportPadding) {
121
+ bubble.classList.remove("bottom-full", "mb-2");
122
+ bubble.classList.add("top-full", "mt-2");
123
+ }
124
+ }
125
+ }
126
+
127
+ resetPlacement() {
128
+ if (!this.hasBubbleTarget) return;
129
+
130
+ const bubble = this.bubbleTarget;
131
+ bubble.classList.remove("right-0", "bottom-full", "mb-2");
132
+ bubble.classList.add("left-0", "top-full", "mt-2");
133
+ bubble.style.transform = "";
134
+ bubble.style.maxWidth = "";
135
+ }
136
+ }