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,118 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ['option', 'panel', 'panels']
5
+ static values = { tab: String }
6
+
7
+ connect() {
8
+ this.updateView()
9
+ this.syncHeight()
10
+ this.handleResize = () => this.syncHeight()
11
+ window.addEventListener('resize', this.handleResize)
12
+ }
13
+
14
+ disconnect() {
15
+ window.removeEventListener('resize', this.handleResize)
16
+ }
17
+
18
+ select(event) {
19
+ const tab = event.currentTarget?.dataset?.value
20
+ if (!tab) return
21
+
22
+ event.preventDefault()
23
+ this.tabValue = tab
24
+ }
25
+
26
+ tabValueChanged() {
27
+ this.updateView()
28
+ }
29
+
30
+ updateView() {
31
+ const tab = this.tabValue || this.defaultTab()
32
+ if (!tab) return
33
+
34
+ this.optionTargets.forEach((button) => {
35
+ const selected = button.dataset.value === tab
36
+ button.classList.toggle('is-selected', selected)
37
+ button.setAttribute('aria-selected', selected ? 'true' : 'false')
38
+ button.setAttribute('tabindex', selected ? '0' : '-1')
39
+ })
40
+
41
+ this.panelTargets.forEach((panel) => {
42
+ const active = panel.dataset.tab === tab
43
+ panel.classList.toggle('hidden', !active)
44
+ panel.setAttribute('aria-hidden', active ? 'false' : 'true')
45
+
46
+ if (active) {
47
+ panel.removeAttribute('hidden')
48
+ panel.classList.remove('motion-slide-up')
49
+ void panel.offsetHeight
50
+ panel.classList.add('motion-slide-up')
51
+ } else {
52
+ panel.setAttribute('hidden', 'true')
53
+ panel.classList.remove('motion-slide-up')
54
+ }
55
+ })
56
+
57
+ this.syncHeight()
58
+ }
59
+
60
+ defaultTab() {
61
+ return this.optionTargets[0]?.dataset?.value
62
+ }
63
+
64
+ syncHeight() {
65
+ if (!this.hasPanelsTarget) return
66
+
67
+ const activePanel = this.activePanel()
68
+ if (!activePanel) return
69
+
70
+ const height = this.measurePanel(activePanel)
71
+ if (height > 0) {
72
+ this.panelsTarget.style.height = `${height}px`
73
+ }
74
+ }
75
+
76
+ activePanel() {
77
+ const tab = this.tabValue || this.defaultTab()
78
+ return this.panelTargets.find((panel) => panel.dataset.tab === tab)
79
+ }
80
+
81
+ measurePanel(panel) {
82
+ const wasHidden = panel.hasAttribute('hidden') || panel.classList.contains('hidden')
83
+ const original = {
84
+ position: panel.style.position,
85
+ visibility: panel.style.visibility,
86
+ pointerEvents: panel.style.pointerEvents,
87
+ left: panel.style.left,
88
+ right: panel.style.right,
89
+ width: panel.style.width
90
+ }
91
+
92
+ if (wasHidden) {
93
+ panel.classList.remove('hidden')
94
+ panel.removeAttribute('hidden')
95
+ panel.style.position = 'absolute'
96
+ panel.style.visibility = 'hidden'
97
+ panel.style.pointerEvents = 'none'
98
+ panel.style.left = '0'
99
+ panel.style.right = '0'
100
+ panel.style.width = '100%'
101
+ }
102
+
103
+ const height = panel.offsetHeight
104
+
105
+ if (wasHidden) {
106
+ panel.classList.add('hidden')
107
+ panel.setAttribute('hidden', 'true')
108
+ panel.style.position = original.position
109
+ panel.style.visibility = original.visibility
110
+ panel.style.pointerEvents = original.pointerEvents
111
+ panel.style.left = original.left
112
+ panel.style.right = original.right
113
+ panel.style.width = original.width
114
+ }
115
+
116
+ return height
117
+ }
118
+ }
@@ -0,0 +1,401 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { queryAll, addClass, removeClass, toggleClass } from "baldur/lib/dom-helpers";
3
+ import { updateAriaExpanded, updateAriaChecked } from "baldur/lib/focus-management";
4
+ import { smoothScroll } from "baldur/lib/animation-helpers";
5
+
6
+ export default class MenuSelectController extends Controller {
7
+ static targets = ["trigger", "list", "input", "label"];
8
+ static values = {
9
+ typeaheadTimeout: { type: Number, default: 600 }
10
+ };
11
+
12
+ connect() {
13
+ this.element.__stimulusController = this;
14
+ this.element.__menuSelectSync = this.syncValue.bind(this);
15
+ this.typeaheadState = { query: "", timeout: null };
16
+ this.handleViewportChange = this.handleViewportChange.bind(this);
17
+ this.listElement = this.hasListTarget ? this.listTarget : null;
18
+ this.listPlaceholder = null;
19
+ this.listOriginalParent = null;
20
+ this.listOriginalNextSibling = null;
21
+ this.init();
22
+ }
23
+
24
+ init() {
25
+ if (this.hasTriggerTarget && this.listElement && this.hasInputTarget) {
26
+ this.setupTrigger();
27
+ this.setupMenuSelection();
28
+ this.attachDocumentClose();
29
+ this.syncOptions();
30
+ }
31
+ }
32
+
33
+ setupTrigger() {
34
+ this.triggerTarget.addEventListener("click", (e) => {
35
+ e.preventDefault();
36
+ this.toggleOpen();
37
+ });
38
+ }
39
+
40
+ toggleOpen() {
41
+ if (this.element.classList.contains("is-open")) {
42
+ this.close();
43
+ } else {
44
+ this.open();
45
+ }
46
+ }
47
+
48
+ open() {
49
+ if (MenuSelectController.activeMenu && MenuSelectController.activeMenu !== this.element) {
50
+ MenuSelectController.activeMenu.__stimulusController?.close();
51
+ }
52
+
53
+ this.resetPlacement();
54
+ this.portalListIfNeeded();
55
+ addClass(this.element, "is-open");
56
+ updateAriaExpanded(this.triggerTarget, true);
57
+ MenuSelectController.activeMenu = this.element;
58
+ this.toggleOverflowContainer(true);
59
+ this.attachViewportListeners();
60
+ requestAnimationFrame(() => this.applyBestPlacement());
61
+ }
62
+
63
+ close() {
64
+ removeClass(this.element, "is-open");
65
+ updateAriaExpanded(this.triggerTarget, false);
66
+ this.resetTypeahead();
67
+ this.detachViewportListeners();
68
+ this.toggleOverflowContainer(false);
69
+ this.resetPlacement();
70
+ this.restorePortaledList();
71
+ if (MenuSelectController.activeMenu === this.element) {
72
+ MenuSelectController.activeMenu = null;
73
+ }
74
+ }
75
+
76
+ setupMenuSelection() {
77
+ this.listElement.addEventListener("click", (e) => {
78
+ const option = e.target.closest("[data-menu-select-option]");
79
+ if (!option || option.dataset.disabled === "true") return;
80
+
81
+ e.preventDefault();
82
+ const value = option.dataset.value;
83
+ const label = option.dataset.label || option.textContent.trim();
84
+ this.syncValue(value, label, true);
85
+ this.close();
86
+ });
87
+
88
+ this.element.addEventListener("keydown", (e) => {
89
+ if (e.key === "Escape") {
90
+ this.close();
91
+ return;
92
+ }
93
+
94
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
95
+
96
+ if (e.key.length === 1 && e.key.trim().length) {
97
+ this.handleTypeahead(e.key);
98
+ e.preventDefault();
99
+ }
100
+
101
+ if (e.key === " ") {
102
+ this.handleTypeahead(" ");
103
+ e.preventDefault();
104
+ }
105
+ });
106
+ }
107
+
108
+ handleTypeahead(char) {
109
+ if (!char) return;
110
+
111
+ this.typeaheadState.query += char.toLowerCase();
112
+
113
+ if (this.typeaheadState.timeout) {
114
+ clearTimeout(this.typeaheadState.timeout);
115
+ }
116
+
117
+ this.typeaheadState.timeout = setTimeout(() => {
118
+ this.typeaheadState.query = "";
119
+ }, this.typeaheadTimeoutValue);
120
+
121
+ if (!this.element.classList.contains("is-open")) {
122
+ this.triggerTarget.click();
123
+ }
124
+
125
+ const match = this.findMatchInOptions(this.typeaheadState.query);
126
+ if (!match && this.typeaheadState.query.length > 1) {
127
+ const singleCharMatch = this.findMatchInOptions(char.toLowerCase());
128
+ if (singleCharMatch) {
129
+ this.applyMatch(singleCharMatch);
130
+ }
131
+ } else if (match) {
132
+ this.applyMatch(match);
133
+ }
134
+ }
135
+
136
+ findMatchInOptions(query) {
137
+ if (!query) return null;
138
+
139
+ const normalized = query.toLowerCase();
140
+ const options = queryAll("[data-menu-select-option]", this.listElement);
141
+
142
+ for (const option of options) {
143
+ if (option.dataset.disabled === "true") continue;
144
+
145
+ const label = (option.dataset.label || option.textContent || "").toLowerCase();
146
+ if (label.indexOf(normalized) === 0) {
147
+ return option;
148
+ }
149
+ }
150
+
151
+ return null;
152
+ }
153
+
154
+ applyMatch(option) {
155
+ const value = option.dataset.value;
156
+ const label = option.dataset.label || option.textContent.trim();
157
+ this.syncValue(value, label, true);
158
+ smoothScroll(option, { block: "nearest" });
159
+ }
160
+
161
+ syncOptions(value = null, label = null, emitEvents = false) {
162
+ const newValue = value ?? this.inputTarget.value;
163
+ this.inputTarget.value = newValue;
164
+
165
+ const options = queryAll("[data-menu-select-option]", this.listElement);
166
+ let activeOption = null;
167
+
168
+ options.forEach((option) => {
169
+ const isSelected = option.dataset.value === newValue && option.dataset.disabled !== "true";
170
+ if (isSelected) activeOption = option;
171
+ toggleClass(option, "is-selected", isSelected);
172
+ updateAriaChecked(option, isSelected);
173
+ });
174
+
175
+ const computedLabel = label || (activeOption && (activeOption.dataset.label || activeOption.textContent.trim()));
176
+ if (this.hasLabelTarget && computedLabel) {
177
+ this.labelTarget.textContent = computedLabel;
178
+ }
179
+
180
+ if (emitEvents) {
181
+ this.inputTarget.dispatchEvent(new Event("input", { bubbles: true }));
182
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }));
183
+ }
184
+ }
185
+
186
+ syncValue(value, label = null, emitEvents = false) {
187
+ this.syncOptions(value, label, emitEvents);
188
+ }
189
+
190
+ resetTypeahead() {
191
+ this.typeaheadState.query = "";
192
+ if (this.typeaheadState.timeout) {
193
+ clearTimeout(this.typeaheadState.timeout);
194
+ this.typeaheadState.timeout = null;
195
+ }
196
+ }
197
+
198
+ handleViewportChange(event) {
199
+ if (!this.element.classList.contains("is-open")) return;
200
+ if (this.shouldIgnoreViewportEvent(event)) return;
201
+ requestAnimationFrame(() => this.applyBestPlacement());
202
+ }
203
+
204
+ attachViewportListeners() {
205
+ window.addEventListener("resize", this.handleViewportChange);
206
+ window.addEventListener("scroll", this.handleViewportChange, true);
207
+ }
208
+
209
+ detachViewportListeners() {
210
+ window.removeEventListener("resize", this.handleViewportChange);
211
+ window.removeEventListener("scroll", this.handleViewportChange, true);
212
+ }
213
+
214
+ applyBestPlacement() {
215
+ if (!this.hasTriggerTarget || !this.listElement) return;
216
+
217
+ const viewportPadding = 8;
218
+ const menuGap = parseFloat(getComputedStyle(this.element).getPropertyValue("--space-2")) || 8;
219
+ const triggerRect = this.triggerTarget.getBoundingClientRect();
220
+ const list = this.listElement;
221
+ const preferredHeight = Math.min(list.scrollHeight || 0, 320, window.innerHeight * 0.5);
222
+ const availableBelow = Math.max(window.innerHeight - triggerRect.bottom - viewportPadding - menuGap, 0);
223
+ const availableAbove = Math.max(triggerRect.top - viewportPadding - menuGap, 0);
224
+
225
+ let placement = "bottom";
226
+ if (preferredHeight > availableBelow && availableAbove > availableBelow) {
227
+ placement = "top";
228
+ }
229
+
230
+ const availableHeight = placement === "top" ? availableAbove : availableBelow;
231
+ const maxHeight = Math.floor(Math.min(Math.max(availableHeight, 0), 320, window.innerHeight * 0.5));
232
+
233
+ this.element.dataset.menuPlacement = placement;
234
+
235
+ if (this.isPortaled()) {
236
+ this.positionPortaledList({
237
+ triggerRect,
238
+ placement,
239
+ maxHeight,
240
+ viewportPadding,
241
+ menuGap
242
+ });
243
+ return;
244
+ }
245
+
246
+ if (maxHeight > 0) {
247
+ list.style.maxHeight = `${maxHeight}px`;
248
+ }
249
+
250
+ list.style.left = "0px";
251
+ let rect = list.getBoundingClientRect();
252
+ const minLeft = viewportPadding;
253
+ const maxRight = window.innerWidth - viewportPadding;
254
+
255
+ if (rect.left < minLeft) {
256
+ list.style.left = `${Math.ceil(minLeft - rect.left)}px`;
257
+ rect = list.getBoundingClientRect();
258
+ }
259
+
260
+ if (rect.right > maxRight) {
261
+ list.style.left = `${Math.floor(parseFloat(list.style.left || "0") - (rect.right - maxRight))}px`;
262
+ }
263
+ }
264
+
265
+ positionPortaledList({ triggerRect, placement, maxHeight, viewportPadding, menuGap }) {
266
+ const list = this.listElement;
267
+ const maxWidth = Math.min(window.innerWidth - (viewportPadding * 2), 352);
268
+
269
+ list.style.position = "fixed";
270
+ list.style.right = "auto";
271
+ list.style.bottom = "auto";
272
+ list.style.display = "block";
273
+ list.style.pointerEvents = "auto";
274
+ list.style.opacity = "1";
275
+ list.style.transform = "none";
276
+ list.style.transformOrigin = placement === "top" ? "bottom left" : "top left";
277
+ list.style.zIndex = "10050";
278
+ list.style.maxHeight = maxHeight > 0 ? `${maxHeight}px` : "";
279
+ list.style.maxWidth = `${Math.floor(maxWidth)}px`;
280
+ list.style.minWidth = `${Math.ceil(triggerRect.width)}px`;
281
+ list.style.width = `${Math.ceil(Math.max(triggerRect.width, Math.min(list.scrollWidth || triggerRect.width, maxWidth)))}px`;
282
+
283
+ const measuredRect = list.getBoundingClientRect();
284
+ const unclampedLeft = triggerRect.left;
285
+ const left = Math.min(
286
+ Math.max(unclampedLeft, viewportPadding),
287
+ Math.max(viewportPadding, window.innerWidth - measuredRect.width - viewportPadding)
288
+ );
289
+ const top = placement === "top"
290
+ ? Math.max(viewportPadding, triggerRect.top - measuredRect.height - menuGap)
291
+ : Math.min(window.innerHeight - measuredRect.height - viewportPadding, triggerRect.bottom + menuGap);
292
+
293
+ list.style.left = `${Math.round(left)}px`;
294
+ list.style.top = `${Math.round(top)}px`;
295
+ }
296
+
297
+ resetPlacement() {
298
+ if (!this.listElement) return;
299
+
300
+ this.element.dataset.menuPlacement = "bottom";
301
+
302
+ const list = this.listElement;
303
+ list.style.left = "";
304
+ list.style.top = "";
305
+ list.style.right = "";
306
+ list.style.bottom = "";
307
+ list.style.width = "";
308
+ list.style.minWidth = "";
309
+ list.style.maxWidth = "";
310
+ list.style.maxHeight = "";
311
+ list.style.position = "";
312
+ list.style.display = "";
313
+ list.style.pointerEvents = "";
314
+ list.style.opacity = "";
315
+ list.style.transform = "";
316
+ list.style.transformOrigin = "";
317
+ list.style.zIndex = "";
318
+ }
319
+
320
+ toggleOverflowContainer(isOpen) {
321
+ const tableCard = this.element.closest(".table-card");
322
+ if (!tableCard) return;
323
+
324
+ toggleClass(tableCard, "has-open-menu", isOpen);
325
+ }
326
+
327
+ attachDocumentClose() {
328
+ this.documentClickHandler = (e) => {
329
+ if (!MenuSelectController.activeMenu) return;
330
+ if (MenuSelectController.activeMenu !== this.element) return;
331
+ if (this.element.contains(e.target)) return;
332
+ if (this.listElement?.contains(e.target)) return;
333
+
334
+ this.close();
335
+ };
336
+ document.addEventListener("click", this.documentClickHandler);
337
+ }
338
+
339
+ disconnect() {
340
+ this.close();
341
+ if (this.documentClickHandler) {
342
+ document.removeEventListener("click", this.documentClickHandler);
343
+ this.documentClickHandler = null;
344
+ }
345
+ delete this.element.__stimulusController;
346
+ delete this.element.__menuSelectSync;
347
+ }
348
+
349
+ isPortaled() {
350
+ return this.listElement?.dataset.menuSelectPortaled === "true";
351
+ }
352
+
353
+ shouldPortalList() {
354
+ return this.element.closest("[data-modal]") !== null;
355
+ }
356
+
357
+ portalListIfNeeded() {
358
+ if (!this.shouldPortalList() || this.isPortaled() || !this.listElement) return;
359
+
360
+ this.listOriginalParent = this.listElement.parentNode;
361
+ this.listOriginalNextSibling = this.listElement.nextSibling;
362
+ this.listPlaceholder = document.createComment("menu-select-placeholder");
363
+ this.listOriginalParent.insertBefore(this.listPlaceholder, this.listElement);
364
+ document.body.appendChild(this.listElement);
365
+ this.listElement.dataset.menuSelectPortaled = "true";
366
+ }
367
+
368
+ restorePortaledList() {
369
+ if (!this.isPortaled() || !this.listOriginalParent || !this.listElement) return;
370
+
371
+ if (this.listOriginalNextSibling && this.listOriginalNextSibling.parentNode === this.listOriginalParent) {
372
+ this.listOriginalParent.insertBefore(this.listElement, this.listOriginalNextSibling);
373
+ } else if (this.listPlaceholder?.parentNode === this.listOriginalParent) {
374
+ this.listOriginalParent.insertBefore(this.listElement, this.listPlaceholder);
375
+ } else {
376
+ this.listOriginalParent.appendChild(this.listElement);
377
+ }
378
+
379
+ delete this.listElement.dataset.menuSelectPortaled;
380
+
381
+ if (this.listPlaceholder?.parentNode) {
382
+ this.listPlaceholder.parentNode.removeChild(this.listPlaceholder);
383
+ }
384
+
385
+ this.listPlaceholder = null;
386
+ this.listOriginalParent = null;
387
+ this.listOriginalNextSibling = null;
388
+ }
389
+
390
+ shouldIgnoreViewportEvent(event) {
391
+ if (!this.isPortaled()) return false;
392
+ if (!event || event.type !== "scroll" || !this.listElement) return false;
393
+
394
+ const target = event.target;
395
+ if (!target) return false;
396
+
397
+ return target === this.listElement || this.listElement.contains(target);
398
+ }
399
+ }
400
+
401
+ MenuSelectController.activeMenu = null;
@@ -0,0 +1,13 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel"];
5
+
6
+ toggle() {
7
+ this.panelTarget.classList.toggle("hidden");
8
+ }
9
+
10
+ close() {
11
+ this.panelTarget.classList.add("hidden");
12
+ }
13
+ }
@@ -0,0 +1,149 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { queryAll, addClass, removeClass } from "baldur/lib/dom-helpers";
3
+ import { focusFirstFocusable, updateAriaHidden } from "baldur/lib/focus-management";
4
+ import { getMotionTimings } from "baldur/lib/animation-helpers";
5
+
6
+ export default class ModalController extends Controller {
7
+ static targets = ["dialog"];
8
+ static values = { selector: String };
9
+
10
+ connect() {
11
+ this.timings = getMotionTimings();
12
+ this.dialogElements = this.hasDialogTarget ? this.dialogTargets : [this.element];
13
+ this.previouslyFocusedElement = null;
14
+ this.setupDialogTriggers();
15
+ this.attachCloseHandlers();
16
+ this.attachKeyboardHandlers();
17
+ }
18
+
19
+ setupDialogTriggers() {
20
+ const triggers = queryAll(`[data-open-modal="${this.selectorValue}"]`);
21
+ triggers.forEach((trigger) => {
22
+ trigger.addEventListener("click", (e) => {
23
+ e.preventDefault();
24
+ this.previouslyFocusedElement = trigger;
25
+ this.open();
26
+ });
27
+ });
28
+ }
29
+
30
+ attachKeyboardHandlers() {
31
+ this.dialogElements.forEach((dialog) => {
32
+ dialog.addEventListener("keydown", (e) => {
33
+ if (e.key === "Escape") this.close();
34
+ });
35
+
36
+ dialog.addEventListener("click", (e) => {
37
+ if (e.target === dialog) this.close();
38
+ });
39
+ });
40
+ }
41
+
42
+ attachCloseHandlers() {
43
+ const closers = queryAll("[data-modal-close]", this.element);
44
+ closers.forEach((closer) => {
45
+ closer.addEventListener("click", (e) => {
46
+ e.preventDefault();
47
+ this.close();
48
+ });
49
+ });
50
+ }
51
+
52
+ open() {
53
+ if (!this.previouslyFocusedElement) {
54
+ this.previouslyFocusedElement = document.activeElement;
55
+ }
56
+
57
+ this.dialogElements.forEach((dialog) => {
58
+ clearTimeout(dialog.__modalHideTimeout);
59
+ removeClass(dialog, "hidden", "is-hiding");
60
+ dialog.removeAttribute("inert");
61
+ updateAriaHidden(dialog, false);
62
+
63
+ requestAnimationFrame(() => {
64
+ addClass(dialog, "is-visible");
65
+ focusFirstFocusable(dialog);
66
+ this.dispatchOpenedHooks(dialog);
67
+ });
68
+ });
69
+ }
70
+
71
+ dispatchOpenedHooks(dialog) {
72
+ const detail = {
73
+ id: dialog?.id || null,
74
+ selector: this.selectorValue || null,
75
+ element: dialog
76
+ };
77
+
78
+ dialog?.dispatchEvent(new CustomEvent("baldur:modal:opened", { bubbles: true, detail }));
79
+ window.dispatchEvent(new CustomEvent("baldur:modal:opened", { detail }));
80
+
81
+ requestAnimationFrame(() => {
82
+ requestAnimationFrame(() => {
83
+ window.dispatchEvent(new Event("resize"));
84
+ });
85
+ });
86
+ }
87
+
88
+ close() {
89
+ this.closeOpenMenus();
90
+
91
+ this.dialogElements.forEach((dialog) => {
92
+ this.pauseMedia(dialog);
93
+ this.restoreFocus(dialog);
94
+
95
+ removeClass(dialog, "is-visible");
96
+ addClass(dialog, "is-hiding");
97
+ dialog.setAttribute("inert", "");
98
+ updateAriaHidden(dialog, true);
99
+
100
+ clearTimeout(dialog.__modalHideTimeout);
101
+ dialog.__modalHideTimeout = setTimeout(() => {
102
+ addClass(dialog, "hidden");
103
+ removeClass(dialog, "is-hiding");
104
+ }, this.timings.fadeOut);
105
+ });
106
+ }
107
+
108
+ pauseMedia(dialog) {
109
+ const mediaElements = dialog?.querySelectorAll("video, audio") || [];
110
+ mediaElements.forEach((mediaElement) => {
111
+ if (typeof mediaElement.pause === "function") {
112
+ mediaElement.pause();
113
+ }
114
+ });
115
+ }
116
+
117
+ closeOpenMenus() {
118
+ const openMenus = this.element.querySelectorAll(".menu-select.is-open");
119
+
120
+ openMenus.forEach((menu) => {
121
+ menu.__stimulusController?.close?.();
122
+ });
123
+ }
124
+
125
+ restoreFocus(dialog) {
126
+ const activeElement = document.activeElement;
127
+ if (!dialog || !activeElement || !dialog.contains(activeElement)) return;
128
+
129
+ const fallback = this.previouslyFocusedElement;
130
+ if (fallback && document.body.contains(fallback) && typeof fallback.focus === "function") {
131
+ fallback.focus({ preventScroll: true });
132
+ return;
133
+ }
134
+
135
+ const root = document.querySelector("main") || document.body;
136
+ if (!root) return;
137
+
138
+ const needsTabIndex = !root.hasAttribute("tabindex");
139
+ if (needsTabIndex) root.setAttribute("tabindex", "-1");
140
+
141
+ root.focus({ preventScroll: true });
142
+
143
+ if (needsTabIndex) {
144
+ setTimeout(() => {
145
+ if (document.body.contains(root)) root.removeAttribute("tabindex");
146
+ }, 0);
147
+ }
148
+ }
149
+ }
@@ -0,0 +1 @@
1
+ export { default } from "baldur/controllers/panel_secondary_controller";