@profoundry-us/loco_motion 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -759,7 +759,7 @@ gem "loco_motion", github: "profoundry-us/loco_motion", branch: "main", require:
759
759
 
760
760
  # or
761
761
 
762
- gem "loco_motion-rails", "0.0.7", require: "loco_motion"
762
+ gem "loco_motion-rails", "0.4.0", require: "loco_motion"
763
763
  ```
764
764
 
765
765
  Next add the following lines to the `contents` section of your
@@ -989,6 +989,11 @@ the GitHub Discussions feature and let us know!
989
989
  under certain conditions
990
990
  - [ ] Add title and description content_for blocks to all examples for SEO purposes
991
991
  - [ ] Update to Tailwind 4 and DaisyUI 5
992
+ - [ ] Rename the `Dockerfile` to `Dockerfile.loco` to be more concise
993
+ - [x] See if we can remove all of the `set_loco_parent` calls in favor of using
994
+ the `lib/loco_motion/patches/view_component/slot_loco_parent_patch.rb`
995
+ - [ ] Make the tooltips documentation button a component and use it for the
996
+ Labelable concern docs too
992
997
 
993
998
  [1]: https://loco-motion.profoundry.us/
994
999
  [2]: https://loco-motion-demo-staging.profoundry.us/
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Theme Controller
3
+ *
4
+ * A Stimulus controller that manages theme selection and persistence.
5
+ * It handles theme switching, localStorage persistence, and synchronization
6
+ * across multiple theme selectors on the same page.
7
+ */
8
+ import { Controller } from "@hotwired/stimulus"
9
+
10
+ export default class extends Controller {
11
+ /**
12
+ * Called when the controller is connected to the DOM.
13
+ * Sets the initial theme input state and sets up event listeners.
14
+ */
15
+ connect() {
16
+ this.setInput()
17
+
18
+ // Setup a custom listener to watch for changes on the page in case the
19
+ // page has multiple theme selectors
20
+ this.storageChangeListener = this.storageChanged.bind(this)
21
+
22
+ window.addEventListener('localstorage-update', this.storageChangeListener)
23
+ }
24
+
25
+ /**
26
+ * Called when the controller is disconnected from the DOM.
27
+ * Removes event listeners to prevent memory leaks.
28
+ */
29
+ disconnect() {
30
+ window.removeEventListener('localstorage-update', this.storageChangeListener)
31
+ }
32
+
33
+ /**
34
+ * Sets the appropriate radio input as checked based on the current theme.
35
+ * This ensures the UI reflects the active theme.
36
+ */
37
+ setInput() {
38
+ const theme = this.getCurrentTheme()
39
+ const input = this.element.querySelector(`input[value='${theme}']`);
40
+
41
+ if (input) {
42
+ input.checked = true;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Clears the user's theme preference from localStorage.
48
+ * Removes the saved theme and dispatches an event to notify other controllers.
49
+ *
50
+ * @param {Event} event - The triggering click event
51
+ */
52
+ clearTheme(event) {
53
+ // If we are passed a themeName parameter, clear all inputs with that theme
54
+ if (event && event.params && event.params.themeName) {
55
+ const inputs = document.querySelectorAll(`input[name='${event.params.themeName}']`)
56
+
57
+ if (inputs) {
58
+ inputs.forEach(input => {
59
+ input.checked = false
60
+ })
61
+ }
62
+ }
63
+
64
+ // Remove the savedTheme from local storage
65
+ localStorage.removeItem("savedTheme")
66
+
67
+ // Fire off an update
68
+ const updateEvent = new CustomEvent('localstorage-update', { detail: { key: 'savedTheme', newValue: null } })
69
+ window.dispatchEvent(updateEvent)
70
+ }
71
+
72
+ /**
73
+ * Changes the theme based on user selection.
74
+ * Updates localStorage and dispatches a custom event to notify other controllers.
75
+ *
76
+ * @param {Event} event - The triggering click event
77
+ */
78
+ setTheme(event) {
79
+ const input = event.currentTarget.querySelector('input')
80
+
81
+ if (input) {
82
+ localStorage.setItem("savedTheme", input.value)
83
+
84
+ const updateEvent = new CustomEvent('localstorage-update', { detail: { key: 'savedTheme', newValue: input.value } })
85
+ window.dispatchEvent(updateEvent)
86
+ }
87
+
88
+ event.preventDefault();
89
+ }
90
+
91
+ /**
92
+ * Retrieves the current theme from localStorage.
93
+ *
94
+ * @returns {string} The current theme name
95
+ */
96
+ getCurrentTheme() {
97
+ const savedTheme = localStorage.getItem('savedTheme')
98
+
99
+ if (savedTheme) {
100
+ return savedTheme
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Event handler for 'localstorage-update' events.
106
+ * Updates the input state when theme changes in another controller.
107
+ *
108
+ * @param {CustomEvent} event - The storage changed event
109
+ */
110
+ storageChanged(event) {
111
+ this.setInput()
112
+ }
113
+ }
@@ -0,0 +1,235 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Controller for handling calendar input interactions.
5
+ * Manages the popover calendar UI and synchronization between input and calendar elements.
6
+ */
7
+ export default class extends Controller {
8
+ static targets = ["calendar", "input", "popover"]
9
+
10
+ /**
11
+ * Initializes the controller and sets up event listeners.
12
+ * Binds all methods to ensure proper 'this' context.
13
+ *
14
+ * @returns {void}
15
+ */
16
+ connect() {
17
+
18
+ // Save the bound functions so we can remove them later
19
+ this.boundUpdateInput = this.updateInput.bind(this)
20
+ this.boundUpdateCalendar = this.updateCalendar.bind(this)
21
+ this.boundOpenCalendar = this.openCalendar.bind(this)
22
+ this.boundCloseCalendar = this.closeCalendar.bind(this)
23
+ this.boundHandleKeydown = this.handleKeydown.bind(this)
24
+ this.boundHandleToggle = this.handleToggle.bind(this)
25
+
26
+ // Bind all of our functions
27
+ this.calendarTarget.addEventListener("change", this.boundUpdateInput)
28
+ this.calendarTarget.addEventListener("blur", this.boundCloseCalendar)
29
+
30
+ this.inputTarget.addEventListener("change", this.boundUpdateCalendar)
31
+ this.inputTarget.addEventListener("keyup", this.boundUpdateCalendar)
32
+ this.inputTarget.addEventListener("click", this.boundOpenCalendar)
33
+ this.inputTarget.addEventListener("keydown", this.boundHandleKeydown)
34
+
35
+ this.popoverTarget.addEventListener("toggle", this.boundHandleToggle)
36
+ }
37
+
38
+ /**
39
+ * Cleans up event listeners when the controller is disconnected.
40
+ * Prevents memory leaks by removing all bound event handlers.
41
+ *
42
+ * @returns {void}
43
+ */
44
+ disconnect() {
45
+ this.calendarTarget.removeEventListener("change", this.boundUpdateInput)
46
+ this.calendarTarget.removeEventListener("blur", this.boundCloseCalendar)
47
+
48
+ this.inputTarget.removeEventListener("change", this.boundUpdateCalendar)
49
+ this.inputTarget.removeEventListener("keyup", this.boundUpdateCalendar)
50
+ this.inputTarget.removeEventListener("keydown", this.boundHandleKeydown)
51
+ this.inputTarget.removeEventListener("click", this.boundOpenCalendar)
52
+ this.popoverTarget.removeEventListener("toggle", this.boundHandleToggle)
53
+ }
54
+
55
+ /**
56
+ * Opens the calendar popover.
57
+ *
58
+ * @returns {void}
59
+ */
60
+ openCalendar() {
61
+ // Open the popover
62
+ this.popoverTarget.togglePopover(true)
63
+ }
64
+
65
+ /**
66
+ * Closes the calendar popover if the blur event is not related to calendar elements.
67
+ *
68
+ * @param {FocusEvent} event - The blur event object.
69
+ *
70
+ * @returns {void}
71
+ */
72
+ closeCalendar(event) {
73
+ // Don't close if we're still in the calendar elements
74
+ if (event?.relatedTarget && this.calendarTarget.contains(event.relatedTarget)) {
75
+ return
76
+ }
77
+
78
+ this.popoverTarget.togglePopover(false)
79
+ }
80
+
81
+ /**
82
+ * Opens the calendar if it is closed, or closes it if it is open.
83
+ *
84
+ * @returns {void}
85
+ */
86
+ toggleCalendar() {
87
+ this.popoverTarget.togglePopover()
88
+ }
89
+
90
+ /**
91
+ * Handles the popover toggle event.
92
+ * - When opening: Sets a zero-width space in empty inputs to prevent floating label flicker
93
+ * - When closing: Clears the single-space input to ensure proper floating label behavior
94
+ *
95
+ * @param {Event} event - The toggle event object with newState property
96
+ * @returns {void}
97
+ */
98
+ handleToggle(event) {
99
+ const hasFloatingLabel = this.inputTarget.parentElement.classList.contains("floating-label")
100
+ const isOpen = event.newState == 'open'
101
+
102
+ if (isOpen) {
103
+ // Make sure the calendar is visible
104
+ this.scrollCalendarIntoView()
105
+ }
106
+
107
+ if (hasFloatingLabel) {
108
+ const ZERO_WIDTH_SPACE = '\u200B'
109
+
110
+ if (isOpen) {
111
+ // Set the input to a zero-width space if it is empty to prevent a floating label
112
+ // flicker when the calendar loses focus while setting the date
113
+ if (this.inputTarget.value == null || this.inputTarget.value === '') {
114
+ this.inputTarget.value = ZERO_WIDTH_SPACE;
115
+ }
116
+ } else {
117
+ // Unset the zero-width space input on close so the floating label works again
118
+ if (this.inputTarget.value === ZERO_WIDTH_SPACE) {
119
+ this.inputTarget.value = null
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Ensures the calendar popover is visible within the viewport.
127
+ *
128
+ * If the popover extends beyond the viewport edges, scrolls the window to
129
+ * bring it into view adding any data-auto-scroll-padding.
130
+ *
131
+ * @returns {void}
132
+ */
133
+ scrollCalendarIntoView() {
134
+ const rect = this.popoverTarget.getBoundingClientRect()
135
+ const autoScrollPadding = parseInt(this.popoverTarget.dataset.autoScrollPadding, 10)
136
+ const padding = isNaN(autoScrollPadding) ? 0 : autoScrollPadding
137
+
138
+ if (rect.bottom > window.innerHeight) {
139
+ window.scrollBy({ top: rect.bottom - window.innerHeight + padding, behavior: 'smooth' })
140
+ } else if (rect.top < 0) {
141
+ window.scrollBy({ top: rect.top - padding, behavior: 'smooth' })
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Updates the input value from the calendar and closes the popover.
147
+ * Synchronizes the input field with the selected date.
148
+ *
149
+ * @returns {void}
150
+ */
151
+ updateInput() {
152
+ // Update the input value and close the popover
153
+ this.inputTarget.value = this.calendarTarget.value
154
+ this.closeCalendar()
155
+ }
156
+
157
+ /**
158
+ * Handles keyboard navigation for the calendar input.
159
+ * - SPACE: Toggles the popover
160
+ * - ENTER: Closes an open popover
161
+ * - ARROW DOWN / ARROW UP: Opens the popover and focuses the calendar
162
+ *
163
+ * @param {KeyboardEvent} event - The keyboard event object.
164
+ *
165
+ * @returns {void}
166
+ */
167
+ handleKeydown(event) {
168
+ const hasModifierKeys = event.ctrlKey || event.shiftKey || event.altKey || event.metaKey;
169
+ const isOpen = this.popoverTarget.matches(':popover-open')
170
+
171
+ //
172
+ // SPACE - Toggles the popover.
173
+ //
174
+ if (event.key === ' ' || event.code === 'Space') {
175
+ event.preventDefault()
176
+
177
+ this.toggleCalendar()
178
+ }
179
+
180
+ //
181
+ // ENTER - Closes an open popover
182
+ //
183
+ else if (isOpen && (event.key === 'Enter' || event.code === 'Enter')) {
184
+ // Stop the enter key from triggering the form submission
185
+ event.preventDefault()
186
+
187
+ this.closeCalendar()
188
+ }
189
+
190
+ //
191
+ // ARROW DOWN / ARROW UP - Opens the popover and focuses the calendar
192
+ //
193
+ else if ((event.key === 'ArrowDown' || event.code === 'ArrowUp') && !hasModifierKeys) {
194
+ event.preventDefault()
195
+
196
+ // If the calendar is not already open, open it
197
+ if (!isOpen) {
198
+ this.openCalendar()
199
+ }
200
+
201
+ // Focus the calendar element
202
+ this.calendarTarget.focus()
203
+
204
+ // Forward the keydown event to the calendar
205
+ const forwardedEvent = new KeyboardEvent('keydown', {
206
+ key: event.key,
207
+ code: event.code,
208
+ bubbles: true,
209
+ cancelable: true,
210
+ })
211
+
212
+ // Dispatch the forwarded event to the calendar
213
+ this.calendarTarget.dispatchEvent(forwardedEvent)
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Updates the calendar's value based on the input field.
219
+ * Only updates if the input contains a valid ISO 8601 date.
220
+ *
221
+ * @returns {void}
222
+ */
223
+ updateCalendar() {
224
+ // Only update the calendar if we have a full / valid ISO 8601 date
225
+ let newDate = this.inputTarget.value
226
+
227
+ if (newDate.length !== 10 || new Date(newDate).toString() === "Invalid Date") {
228
+ return
229
+ }
230
+
231
+ // Set the calendar value and focused-date attributes
232
+ this.calendarTarget.value = newDate
233
+ this.calendarTarget.setAttribute('focused-date', newDate)
234
+ }
235
+ }
package/index.js CHANGED
@@ -1,3 +1,9 @@
1
+ import CallyInputController from './app/components/daisy/data_input/cally_input_controller';
1
2
  import CountdownController from './app/components/daisy/data_display/countdown_controller';
3
+ import ThemeController from './app/components/daisy/actions/theme_controller';
2
4
 
3
- export { CountdownController };
5
+ export {
6
+ CallyInputController,
7
+ CountdownController,
8
+ ThemeController,
9
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profoundry-us/loco_motion",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Crazy fast Rails development!",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -14,6 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "index.js",
17
- "app/components/daisy/data_display/countdown_controller.js"
17
+ "app/components/daisy/data_input/cally_input_controller.js",
18
+ "app/components/daisy/data_display/countdown_controller.js",
19
+ "app/components/daisy/actions/theme_controller.js"
18
20
  ]
19
21
  }