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,558 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { query } from "baldur/lib/dom-helpers";
3
+ import { setFieldValidationMessage } from "baldur/lib/field-validation-helpers";
4
+ import { iconSvg } from "baldur/lib/lucide";
5
+ import {
6
+ parseDisplayDate,
7
+ formatDateForDisplay
8
+ } from "baldur/lib/formatting-helpers";
9
+
10
+ export default class DateFieldController extends Controller {
11
+ static targets = ["display", "native", "toggle"];
12
+
13
+ connect() {
14
+ if (!this.hasDisplayTarget || !this.hasNativeTarget) return;
15
+
16
+ this.setupFieldNames();
17
+ this.syncFromNative();
18
+ this.attachEventListeners();
19
+ this.buildPicker();
20
+ }
21
+
22
+ setupFieldNames() {
23
+ const actualName = this.nativeTarget.dataset.dateFieldName || this.displayTarget.name;
24
+
25
+ if (actualName) {
26
+ this.nativeTarget.name = actualName;
27
+ if (this.displayTarget.name === actualName) {
28
+ this.displayTarget.removeAttribute("name");
29
+ }
30
+ }
31
+ }
32
+
33
+ attachEventListeners() {
34
+ this.displayTarget.addEventListener("blur", () => this.syncFromDisplay());
35
+ this.displayTarget.addEventListener("change", () => this.syncFromDisplay());
36
+ this.displayTarget.addEventListener("paste", this.preventPaste);
37
+ this.displayTarget.addEventListener("input", (event) => {
38
+ if (event.isTrusted) {
39
+ this.applyDateMask(this.displayTarget);
40
+ const wrapper = this.element;
41
+ if (wrapper.classList.contains("is-invalid")) {
42
+ this.updateSupport("");
43
+ }
44
+ }
45
+ });
46
+
47
+ this.nativeTarget.addEventListener("change", () => this.syncFromNative({ emit: true }));
48
+
49
+ if (this.hasToggleTarget) {
50
+ this.toggleTarget.addEventListener("click", (e) => {
51
+ e.preventDefault();
52
+ this.togglePicker();
53
+ });
54
+ this.toggleTarget.setAttribute("aria-expanded", "false");
55
+ }
56
+
57
+ document.addEventListener("mousedown", this.handleDocumentClick);
58
+ document.addEventListener("keydown", this.handleGlobalKeydown);
59
+ }
60
+
61
+ disconnect() {
62
+ document.removeEventListener("mousedown", this.handleDocumentClick);
63
+ document.removeEventListener("keydown", this.handleGlobalKeydown);
64
+ this.displayTarget?.removeEventListener("paste", this.preventPaste);
65
+ }
66
+
67
+ syncFromNative(options = {}) {
68
+ const isoValue = this.nativeTarget.value || "";
69
+ this.displayTarget.value = isoValue ? formatDateForDisplay(isoValue) : "";
70
+ this.updateSupport("");
71
+
72
+ if (options.emit) {
73
+ this.displayTarget.dispatchEvent(new Event("input", { bubbles: true }));
74
+ this.displayTarget.dispatchEvent(new Event("change", { bubbles: true }));
75
+ }
76
+ }
77
+
78
+ syncFromDisplay() {
79
+ const text = this.displayTarget.value || "";
80
+
81
+ if (!text.trim()) {
82
+ this.nativeTarget.value = "";
83
+ this.updateSupport("");
84
+ return;
85
+ }
86
+
87
+ const iso = parseDisplayDate(text);
88
+ if (!iso) {
89
+ const label = this.displayTarget.dataset.fieldLabel || "Date";
90
+ this.updateSupport(`${label} must be a valid date (YYYY-MM-DD)`);
91
+ return;
92
+ }
93
+
94
+ const date = this.dateFromIso(iso);
95
+ const violation = this.dateConstraintViolation(date);
96
+ if (violation) {
97
+ const label = this.displayTarget.dataset.fieldLabel || "Date";
98
+ const direction = violation === "beforeMin" ? "past" : "future";
99
+ this.updateSupport(`${label} cannot be in the ${direction}`);
100
+ return;
101
+ }
102
+
103
+ this.nativeTarget.value = iso;
104
+ this.updateSupport("");
105
+ }
106
+
107
+ updateSupport(message) {
108
+ setFieldValidationMessage(this.element, message);
109
+ }
110
+
111
+ applyDateMask(field) {
112
+ if (!field) return;
113
+ const raw = field.value || "";
114
+ const selection = typeof field.selectionStart === "number" ? field.selectionStart : raw.length;
115
+ const digitsBeforeCursor = raw.slice(0, selection).replace(/\D/g, "").length;
116
+ const digits = raw.replace(/\D/g, "").slice(0, 8);
117
+
118
+ let output = "";
119
+ let caret = 0;
120
+ digits.split("").forEach((char, index) => {
121
+ if (index === 4 || index === 6) output += "-";
122
+ output += char;
123
+ if (index + 1 === digitsBeforeCursor) caret = output.length;
124
+ });
125
+
126
+ if (digitsBeforeCursor >= digits.length) {
127
+ caret = output.length;
128
+ }
129
+
130
+ field.value = output;
131
+ if (field.setSelectionRange) {
132
+ const pos = Math.min(caret, output.length);
133
+ field.setSelectionRange(pos, pos);
134
+ }
135
+ }
136
+
137
+ // --- Picker UI ---
138
+
139
+ buildPicker() {
140
+ const initialDate = this.parseNativeValue() || new Date();
141
+ const clampedDate = this.clampDateToBounds(initialDate);
142
+ this.currentViewDate = clampedDate;
143
+ this.activeDate = clampedDate;
144
+
145
+ this.picker = document.createElement("div");
146
+ this.picker.className = "date-picker";
147
+ this.picker.setAttribute("role", "dialog");
148
+ this.picker.setAttribute("aria-modal", "true");
149
+ this.picker.setAttribute("aria-label", "Choose date");
150
+ this.picker.hidden = true;
151
+
152
+ const header = document.createElement("div");
153
+ header.className = "date-picker__header";
154
+
155
+ const dateControls = document.createElement("div");
156
+ dateControls.className = "date-picker__controls";
157
+
158
+ this.monthSelect = document.createElement("select");
159
+ this.monthSelect.className = "date-picker__month";
160
+ this.monthSelect.setAttribute("aria-label", "Select month");
161
+ const monthNames = Array.from({ length: 12 }, (_, i) =>
162
+ new Date(2000, i, 1).toLocaleDateString(undefined, { month: "short" })
163
+ );
164
+ monthNames.forEach((name, index) => {
165
+ const option = document.createElement("option");
166
+ option.value = index.toString();
167
+ option.textContent = name;
168
+ this.monthSelect.append(option);
169
+ });
170
+ this.monthSelect.addEventListener("change", (event) => {
171
+ const month = Number(event.target.value);
172
+ this.changeMonthTo(month);
173
+ });
174
+
175
+ this.yearInput = document.createElement("input");
176
+ this.yearInput.type = "number";
177
+ this.yearInput.inputMode = "numeric";
178
+ this.yearInput.className = "date-picker__year";
179
+ this.yearInput.setAttribute("aria-label", "Select year");
180
+ this.yearInput.setAttribute("min", "1900");
181
+ this.yearInput.setAttribute("max", "2100");
182
+ this.yearInput.addEventListener("change", () => this.changeYear());
183
+ this.yearInput.addEventListener("blur", () => this.changeYear());
184
+ this.yearInput.addEventListener("keydown", (event) => {
185
+ if (event.key === "Enter") {
186
+ event.preventDefault();
187
+ this.changeYear();
188
+ }
189
+ });
190
+
191
+ dateControls.append(this.monthSelect, this.yearInput);
192
+
193
+ this.prevButton = document.createElement("button");
194
+ this.prevButton.type = "button";
195
+ this.prevButton.className = "date-picker__nav";
196
+ this.prevButton.setAttribute("aria-label", "Previous month");
197
+ this.prevButton.innerHTML = iconSvg("chevron-left", "h-4 w-4");
198
+ this.prevButton.addEventListener("click", () => this.changeMonth(-1));
199
+
200
+ this.nextButton = document.createElement("button");
201
+ this.nextButton.type = "button";
202
+ this.nextButton.className = "date-picker__nav";
203
+ this.nextButton.setAttribute("aria-label", "Next month");
204
+ this.nextButton.innerHTML = iconSvg("chevron-right", "h-4 w-4");
205
+ this.nextButton.addEventListener("click", () => this.changeMonth(1));
206
+
207
+ this.title = document.createElement("div");
208
+ this.title.className = "date-picker__title";
209
+
210
+ header.append(this.prevButton, dateControls, this.nextButton);
211
+ this.picker.append(header);
212
+ const titleRow = document.createElement("div");
213
+ titleRow.className = "date-picker__title-row";
214
+ titleRow.append(this.title);
215
+ this.picker.append(titleRow);
216
+
217
+ const weekdays = document.createElement("div");
218
+ weekdays.className = "date-picker__weekdays";
219
+ const weekdayLabels = ["S", "M", "T", "W", "T", "F", "S"];
220
+ weekdayLabels.forEach((label) => {
221
+ const day = document.createElement("span");
222
+ day.textContent = label;
223
+ day.setAttribute("aria-hidden", "true");
224
+ weekdays.append(day);
225
+ });
226
+ this.picker.append(weekdays);
227
+
228
+ this.grid = document.createElement("div");
229
+ this.grid.className = "date-picker__grid";
230
+ this.grid.setAttribute("role", "grid");
231
+ this.picker.append(this.grid);
232
+
233
+ this.element.append(this.picker);
234
+ this.renderCalendar();
235
+ }
236
+
237
+ togglePicker() {
238
+ if (this.picker.hidden) {
239
+ this.openPicker();
240
+ } else {
241
+ this.closePicker();
242
+ }
243
+ }
244
+
245
+ openPicker() {
246
+ const initialDate = this.parseNativeValue() || new Date();
247
+ const clampedDate = this.clampDateToBounds(initialDate);
248
+ this.currentViewDate = clampedDate;
249
+ this.activeDate = clampedDate;
250
+ this.renderCalendar();
251
+ this.picker.hidden = false;
252
+ this.element.classList.add("is-open");
253
+ if (this.hasToggleTarget) {
254
+ this.toggleTarget.setAttribute("aria-expanded", "true");
255
+ }
256
+ this.focusActiveDay();
257
+ }
258
+
259
+ closePicker() {
260
+ if (this.picker.hidden) return;
261
+ this.picker.hidden = true;
262
+ this.element.classList.remove("is-open");
263
+ if (this.hasToggleTarget) {
264
+ this.toggleTarget.setAttribute("aria-expanded", "false");
265
+ }
266
+ }
267
+
268
+ renderCalendar() {
269
+ const viewYear = this.currentViewDate.getFullYear();
270
+ const viewMonth = this.currentViewDate.getMonth();
271
+ const selectedIso = this.nativeTarget.value;
272
+ const selectedDate = selectedIso ? this.dateFromIso(selectedIso) : null;
273
+ const today = new Date();
274
+
275
+ this.title.textContent = this.currentViewDate.toLocaleDateString(undefined, {
276
+ month: "long",
277
+ year: "numeric"
278
+ });
279
+ this.monthSelect.value = this.currentViewDate.getMonth().toString();
280
+ this.yearInput.value = this.currentViewDate.getFullYear().toString();
281
+
282
+ this.grid.innerHTML = "";
283
+ const startOfMonth = new Date(viewYear, viewMonth, 1);
284
+ const startWeekday = startOfMonth.getDay(); // 0 = Sunday
285
+ const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
286
+ const totalCells = Math.ceil((startWeekday + daysInMonth) / 7) * 7;
287
+
288
+ for (let cell = 0; cell < totalCells; cell += 1) {
289
+ const dayNumber = cell - startWeekday + 1;
290
+ const dayButton = document.createElement("button");
291
+ dayButton.type = "button";
292
+ dayButton.className = "date-picker__day";
293
+ dayButton.setAttribute("role", "gridcell");
294
+
295
+ if (dayNumber < 1 || dayNumber > daysInMonth) {
296
+ dayButton.classList.add("is-outside");
297
+ dayButton.tabIndex = -1;
298
+ this.grid.append(dayButton);
299
+ continue;
300
+ }
301
+
302
+ const cellDate = new Date(viewYear, viewMonth, dayNumber);
303
+ const isoValue = this.isoFromDate(cellDate);
304
+ const isToday = cellDate.toDateString() === today.toDateString();
305
+ const isSelected = selectedDate && cellDate.toDateString() === selectedDate.toDateString();
306
+ const isActive = cellDate.toDateString() === this.activeDate.toDateString();
307
+ const isDisabled = this.isDateDisabled(cellDate);
308
+
309
+ dayButton.textContent = dayNumber.toString();
310
+ dayButton.dataset.dateValue = isoValue;
311
+ dayButton.setAttribute("aria-label", cellDate.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" }));
312
+ dayButton.setAttribute("aria-selected", isSelected ? "true" : "false");
313
+ dayButton.tabIndex = (isActive && !isDisabled) ? 0 : -1;
314
+ dayButton.disabled = isDisabled;
315
+
316
+ if (isSelected) dayButton.classList.add("is-selected");
317
+ if (isToday) dayButton.classList.add("is-today");
318
+ if (isDisabled) dayButton.classList.add("is-disabled");
319
+
320
+ dayButton.addEventListener("click", () => this.selectDate(cellDate));
321
+ dayButton.addEventListener("keydown", (event) => this.handleDayKeydown(event, cellDate));
322
+
323
+ this.grid.append(dayButton);
324
+ }
325
+ }
326
+
327
+ handleDayKeydown(event, cellDate) {
328
+ const key = event.key;
329
+ if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) {
330
+ event.preventDefault();
331
+ const delta = key === "ArrowUp" ? -7 : key === "ArrowDown" ? 7 : key === "ArrowLeft" ? -1 : 1;
332
+ this.moveActiveByDays(delta);
333
+ } else if (key === "PageUp") {
334
+ event.preventDefault();
335
+ this.changeMonth(-1, { keepDay: true });
336
+ } else if (key === "PageDown") {
337
+ event.preventDefault();
338
+ this.changeMonth(1, { keepDay: true });
339
+ } else if (key === "Home") {
340
+ event.preventDefault();
341
+ this.moveActiveToStartOfWeek();
342
+ } else if (key === "End") {
343
+ event.preventDefault();
344
+ this.moveActiveToEndOfWeek();
345
+ } else if (key === "Enter" || key === " ") {
346
+ event.preventDefault();
347
+ this.selectDate(cellDate);
348
+ } else if (key === "Escape") {
349
+ event.preventDefault();
350
+ this.closePicker();
351
+ this.toggleTarget?.focus();
352
+ }
353
+ }
354
+
355
+ handleDocumentClick = (event) => {
356
+ if (this.picker.hidden) return;
357
+ if (this.element.contains(event.target)) return;
358
+ this.closePicker();
359
+ };
360
+
361
+ handleGlobalKeydown = (event) => {
362
+ if (event.key === "Tab" && !this.picker.hidden) {
363
+ this.closePicker();
364
+ }
365
+ };
366
+
367
+ moveActiveByDays(delta) {
368
+ const tentative = new Date(this.activeDate);
369
+ tentative.setDate(this.activeDate.getDate() + delta);
370
+
371
+ const next = this.clampDateToBounds(tentative);
372
+ if (next.toDateString() === this.activeDate.toDateString()) return;
373
+
374
+ this.activeDate = next;
375
+ this.currentViewDate = new Date(next.getFullYear(), next.getMonth(), 1);
376
+ this.renderCalendar();
377
+ this.focusActiveDay();
378
+ }
379
+
380
+ moveActiveToStartOfWeek() {
381
+ const day = this.activeDate.getDay();
382
+ this.moveActiveByDays(-day);
383
+ }
384
+
385
+ moveActiveToEndOfWeek() {
386
+ const day = this.activeDate.getDay();
387
+ this.moveActiveByDays(6 - day);
388
+ }
389
+
390
+ changeMonth(delta, options = {}) {
391
+ const nextMonth = new Date(this.currentViewDate);
392
+ nextMonth.setMonth(this.currentViewDate.getMonth() + delta);
393
+
394
+ const day = options.keepDay ? this.activeDate.getDate() : 1;
395
+ let recalculated = new Date(nextMonth.getFullYear(), nextMonth.getMonth(), day);
396
+
397
+ if (options.keepDay && this.isDateDisabled(recalculated)) {
398
+ recalculated = this.findFirstEnabledDateInMonth(nextMonth.getFullYear(), nextMonth.getMonth());
399
+ }
400
+
401
+ const clamped = this.clampDateToBounds(recalculated);
402
+ this.activeDate = clamped;
403
+ this.currentViewDate = new Date(clamped.getFullYear(), clamped.getMonth(), 1);
404
+ this.renderCalendar();
405
+ this.focusActiveDay();
406
+ }
407
+
408
+ changeMonthTo(monthIndex) {
409
+ if (Number.isNaN(monthIndex)) return;
410
+ const next = new Date(this.currentViewDate);
411
+ next.setMonth(monthIndex);
412
+ const adjusted = this.findFirstEnabledDateInMonth(next.getFullYear(), monthIndex);
413
+ const clamped = this.clampDateToBounds(adjusted);
414
+ this.activeDate = clamped;
415
+ this.currentViewDate = new Date(clamped.getFullYear(), clamped.getMonth(), 1);
416
+ this.renderCalendar();
417
+ this.focusActiveDay();
418
+ }
419
+
420
+ changeYear() {
421
+ const parsed = parseInt(this.yearInput.value, 10);
422
+ if (Number.isNaN(parsed)) return;
423
+ const clampedYear = Math.min(Math.max(parsed, 1900), 2100);
424
+ const next = new Date(this.currentViewDate);
425
+ next.setFullYear(clampedYear);
426
+ const adjusted = this.findFirstEnabledDateInMonth(next.getFullYear(), next.getMonth());
427
+ const clamped = this.clampDateToBounds(adjusted);
428
+ this.activeDate = clamped;
429
+ this.currentViewDate = new Date(clamped.getFullYear(), clamped.getMonth(), 1);
430
+ this.yearInput.value = clampedYear.toString();
431
+ this.renderCalendar();
432
+ this.focusActiveDay();
433
+ }
434
+
435
+ focusActiveDay() {
436
+ const iso = this.isoFromDate(this.activeDate);
437
+ const button = query(`[data-date-value="${iso}"]`, this.grid);
438
+ if (button && !button.disabled) {
439
+ button.focus({ preventScroll: true });
440
+ } else {
441
+ const firstEnabled = query("button:not(.is-outside):not(:disabled)", this.grid);
442
+ firstEnabled?.focus({ preventScroll: true });
443
+ }
444
+ }
445
+
446
+ findFirstEnabledDateInMonth(year, month) {
447
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
448
+
449
+ for (let day = 1; day <= daysInMonth; day++) {
450
+ const testDate = new Date(year, month, day);
451
+ if (!this.isDateDisabled(testDate)) {
452
+ return testDate;
453
+ }
454
+ }
455
+
456
+ const fallback = new Date(year, month, 1);
457
+ return this.clampDateToBounds(fallback);
458
+ }
459
+
460
+ parseNativeValue() {
461
+ const value = this.nativeTarget.value;
462
+ if (!value) return null;
463
+ return this.dateFromIso(value);
464
+ }
465
+
466
+ selectDate(date) {
467
+ if (!date || Number.isNaN(date.getTime())) return;
468
+ if (this.isDateDisabled(date)) return;
469
+ const isoValue = this.isoFromDate(date);
470
+ this.nativeTarget.value = isoValue;
471
+ this.syncFromNative({ emit: true });
472
+ this.closePicker();
473
+ this.toggleTarget?.focus();
474
+ }
475
+
476
+ dateFromIso(value) {
477
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
478
+ if (!match) return null;
479
+ const year = Number(match[1]);
480
+ const month = Number(match[2]) - 1;
481
+ const day = Number(match[3]);
482
+ const date = new Date(year, month, day);
483
+ return Number.isNaN(date.getTime()) ? null : date;
484
+ }
485
+
486
+ isoFromDate(date) {
487
+ const year = date.getFullYear();
488
+ const month = (date.getMonth() + 1).toString().padStart(2, "0");
489
+ const day = date.getDate().toString().padStart(2, "0");
490
+ return `${year}-${month}-${day}`;
491
+ }
492
+
493
+ normalizeDate(date) {
494
+ const normalized = new Date(date);
495
+ normalized.setHours(0, 0, 0, 0);
496
+ return normalized;
497
+ }
498
+
499
+ getMinDate() {
500
+ const minDateAttr = this.nativeTarget.getAttribute("min");
501
+ if (!minDateAttr) return null;
502
+ return this.dateFromIso(minDateAttr);
503
+ }
504
+
505
+ getMaxDate() {
506
+ const maxDateAttr = this.nativeTarget.getAttribute("max");
507
+ if (!maxDateAttr) return null;
508
+ return this.dateFromIso(maxDateAttr);
509
+ }
510
+
511
+ dateConstraintViolation(date) {
512
+ if (!date || Number.isNaN(date.getTime())) return null;
513
+
514
+ const compareDate = this.normalizeDate(date);
515
+
516
+ const minDate = this.getMinDate();
517
+ if (minDate) {
518
+ const normalizedMin = this.normalizeDate(minDate);
519
+ if (compareDate < normalizedMin) return "beforeMin";
520
+ }
521
+
522
+ const maxDate = this.getMaxDate();
523
+ if (maxDate) {
524
+ const normalizedMax = this.normalizeDate(maxDate);
525
+ if (compareDate > normalizedMax) return "afterMax";
526
+ }
527
+
528
+ return null;
529
+ }
530
+
531
+ clampDateToBounds(date) {
532
+ if (!date || Number.isNaN(date.getTime())) return date;
533
+
534
+ const compareDate = this.normalizeDate(date);
535
+
536
+ const minDate = this.getMinDate();
537
+ if (minDate) {
538
+ const normalizedMin = this.normalizeDate(minDate);
539
+ if (compareDate < normalizedMin) return normalizedMin;
540
+ }
541
+
542
+ const maxDate = this.getMaxDate();
543
+ if (maxDate) {
544
+ const normalizedMax = this.normalizeDate(maxDate);
545
+ if (compareDate > normalizedMax) return normalizedMax;
546
+ }
547
+
548
+ return compareDate;
549
+ }
550
+
551
+ isDateDisabled(date) {
552
+ return !!this.dateConstraintViolation(date);
553
+ }
554
+
555
+ preventPaste = (event) => {
556
+ event.preventDefault();
557
+ };
558
+ }
@@ -0,0 +1,30 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class DetailsMenuController extends Controller {
4
+ close() {
5
+ if (!this.element.open) return;
6
+ this.element.open = false;
7
+ }
8
+
9
+ closeIfClickOutside(event) {
10
+ if (!this.element.open) return;
11
+ if (this.element.contains(event.target)) return;
12
+
13
+ this.close();
14
+ }
15
+
16
+ closeIfEscape(event) {
17
+ if (event.key !== "Escape") return;
18
+
19
+ this.close();
20
+ }
21
+
22
+ closeIfLink(event) {
23
+ if (!this.element.open) return;
24
+
25
+ const link = event.target.closest("a");
26
+ if (!link) return;
27
+
28
+ this.close();
29
+ }
30
+ }
@@ -0,0 +1,7 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ submit() {
5
+ this.element.requestSubmit();
6
+ }
7
+ }
@@ -0,0 +1,47 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ['billingOption', 'planCard']
5
+ static values = { billing: String }
6
+
7
+ connect() {
8
+ if (!this.billingValue) {
9
+ this.billingValue = this.defaultBilling()
10
+ }
11
+
12
+ this.render()
13
+ }
14
+
15
+ select(event) {
16
+ const nextBilling = event.currentTarget?.dataset?.value
17
+ if (!nextBilling) return
18
+
19
+ this.billingValue = nextBilling
20
+ this.render()
21
+ }
22
+
23
+ render() {
24
+ this.billingOptionTargets.forEach((option) => {
25
+ const active = option.dataset.value === this.billingValue
26
+ option.classList.toggle('is-selected', active)
27
+ option.setAttribute('aria-selected', active ? 'true' : 'false')
28
+ option.setAttribute('tabindex', active ? '0' : '-1')
29
+ })
30
+
31
+ this.planCardTargets.forEach((card) => {
32
+ const primary = card.dataset[`${this.billingValue}Primary`]
33
+ const secondary = card.dataset[`${this.billingValue}Secondary`]
34
+ const primaryNode = card.querySelector('[data-role="price-primary"]')
35
+ const secondaryNode = card.querySelector('[data-role="price-secondary"]')
36
+
37
+ if (primaryNode && primary) primaryNode.textContent = primary
38
+ if (secondaryNode && secondary) secondaryNode.textContent = secondary
39
+ })
40
+ }
41
+
42
+ defaultBilling() {
43
+ return this.billingOptionTargets.find((option) => option.getAttribute('aria-selected') === 'true')?.dataset?.value ||
44
+ this.billingOptionTargets[0]?.dataset?.value ||
45
+ 'monthly'
46
+ }
47
+ }