b4um_generators 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +28 -0
  4. data/CHANGELOG.md +5 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.de.md +821 -0
  7. data/README.md +815 -0
  8. data/Rakefile +12 -0
  9. data/b4um_generators.gemspec +30 -0
  10. data/bin/console +11 -0
  11. data/bin/setup +8 -0
  12. data/lib/b4um_generators/version.rb +5 -0
  13. data/lib/b4um_generators.rb +8 -0
  14. data/lib/generators/b4um/bento/bento_generator.rb +81 -0
  15. data/lib/generators/b4um/bento/templates/_alternating.html.erb.tt +15 -0
  16. data/lib/generators/b4um/bento/templates/_bento.html.erb.tt +26 -0
  17. data/lib/generators/b4um/bento/templates/_grid.html.erb.tt +13 -0
  18. data/lib/generators/b4um/bento/templates/_list.html.erb.tt +15 -0
  19. data/lib/generators/b4um/comments/comments_generator.rb +259 -0
  20. data/lib/generators/b4um/comments/templates/_comments.html.erb.tt +63 -0
  21. data/lib/generators/b4um/comments/templates/comment.rb.tt +5 -0
  22. data/lib/generators/b4um/comments/templates/comments_controller.rb.tt +45 -0
  23. data/lib/generators/b4um/comments/templates/create_comments.rb.tt +14 -0
  24. data/lib/generators/b4um/controller/controller_generator.rb +397 -0
  25. data/lib/generators/b4um/help/help_generator.rb +755 -0
  26. data/lib/generators/b4um/infinite_scroll/infinite_scroll_generator.rb +199 -0
  27. data/lib/generators/b4um/infinite_scroll/templates/infinite_scroll_controller.js +61 -0
  28. data/lib/generators/b4um/install/install_generator.rb +492 -0
  29. data/lib/generators/b4um/install/templates/_cookie_consent.html.erb +27 -0
  30. data/lib/generators/b4um/install/templates/_footer.html.erb +17 -0
  31. data/lib/generators/b4um/install/templates/_hero.html.erb +33 -0
  32. data/lib/generators/b4um/install/templates/_navigation.html.erb +31 -0
  33. data/lib/generators/b4um/install/templates/_sitemap.html.erb +48 -0
  34. data/lib/generators/b4um/install/templates/_theme_switcher.html.erb +57 -0
  35. data/lib/generators/b4um/install/templates/b4um/badges.css +43 -0
  36. data/lib/generators/b4um/install/templates/b4um/base.css +42 -0
  37. data/lib/generators/b4um/install/templates/b4um/callouts.css +58 -0
  38. data/lib/generators/b4um/install/templates/b4um/cards.css +401 -0
  39. data/lib/generators/b4um/install/templates/b4um/comments.css +52 -0
  40. data/lib/generators/b4um/install/templates/b4um/cookie-consent.css +83 -0
  41. data/lib/generators/b4um/install/templates/b4um/empty-state.css +26 -0
  42. data/lib/generators/b4um/install/templates/b4um/flash.css +86 -0
  43. data/lib/generators/b4um/install/templates/b4um/footer.css +338 -0
  44. data/lib/generators/b4um/install/templates/b4um/forms.css +924 -0
  45. data/lib/generators/b4um/install/templates/b4um/hero.css +149 -0
  46. data/lib/generators/b4um/install/templates/b4um/layout.css +166 -0
  47. data/lib/generators/b4um/install/templates/b4um/lightbox.css +268 -0
  48. data/lib/generators/b4um/install/templates/b4um/navigation.css +257 -0
  49. data/lib/generators/b4um/install/templates/b4um/pagination.css +85 -0
  50. data/lib/generators/b4um/install/templates/b4um/resources.css +121 -0
  51. data/lib/generators/b4um/install/templates/b4um/search.css +41 -0
  52. data/lib/generators/b4um/install/templates/b4um/tables.css +67 -0
  53. data/lib/generators/b4um/install/templates/b4um/theme-switcher.css +178 -0
  54. data/lib/generators/b4um/install/templates/b4um/theme.css +132 -0
  55. data/lib/generators/b4um/install/templates/b4um.css +20 -0
  56. data/lib/generators/b4um/install/templates/b4um.yml +12 -0
  57. data/lib/generators/b4um/install/templates/b4um_helper.rb +27 -0
  58. data/lib/generators/b4um/install/templates/cookie_consent_controller.js +76 -0
  59. data/lib/generators/b4um/install/templates/dismissible_controller.js +7 -0
  60. data/lib/generators/b4um/install/templates/image_lightbox_controller.js +414 -0
  61. data/lib/generators/b4um/install/templates/image_preview_controller.js +85 -0
  62. data/lib/generators/b4um/install/templates/navigation_controller.js +25 -0
  63. data/lib/generators/b4um/install/templates/navigation_helper.rb +26 -0
  64. data/lib/generators/b4um/install/templates/sitemap_controller.js +35 -0
  65. data/lib/generators/b4um/install/templates/theme_controller.js +122 -0
  66. data/lib/generators/b4um/pagination/pagination_generator.rb +179 -0
  67. data/lib/generators/b4um/pagination/templates/_pagination.html.erb.tt +48 -0
  68. data/lib/generators/b4um/pagination/templates/pagination.rb +36 -0
  69. data/lib/generators/b4um/scaffold/scaffold_generator.rb +333 -0
  70. data/lib/generators/b4um/scaffold/templates/_bento.html.erb.tt +26 -0
  71. data/lib/generators/b4um/scaffold/templates/_form.html.erb.tt +218 -0
  72. data/lib/generators/b4um/scaffold/templates/_resource.html.erb.tt +144 -0
  73. data/lib/generators/b4um/scaffold/templates/_table.html.erb.tt +40 -0
  74. data/lib/generators/b4um/scaffold/templates/edit.html.erb.tt +21 -0
  75. data/lib/generators/b4um/scaffold/templates/index.html.erb.tt +24 -0
  76. data/lib/generators/b4um/scaffold/templates/new.html.erb.tt +17 -0
  77. data/lib/generators/b4um/scaffold/templates/show.html.erb.tt +31 -0
  78. data/lib/generators/b4um/search/search_generator.rb +165 -0
  79. data/lib/generators/b4um/search/templates/search.rb +34 -0
  80. data/lib/generators/b4um/search/templates/search_form.html.erb.tt +15 -0
  81. data/lib/generators/b4um/table/table_generator.rb +63 -0
  82. data/lib/generators/b4um/table/templates/_table.html.erb.tt +40 -0
  83. data/lib/generators/b4um/trix/templates/b4um_rich_text_helper.rb +58 -0
  84. data/lib/generators/b4um/trix/templates/b4um_trix.js +551 -0
  85. data/lib/generators/b4um/trix/trix_generator.rb +383 -0
  86. data/sig/b4um_generators.rbs +4 -0
  87. metadata +148 -0
@@ -0,0 +1,414 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ['item', 'overlay', 'stage', 'image', 'caption', 'counter', 'previousButton', 'nextButton']
5
+
6
+ connect() {
7
+ this.currentIndex = 0
8
+ this.isSliding = false
9
+
10
+ this.isDragging = false
11
+ this.dragStartX = 0
12
+ this.dragCurrentX = 0
13
+ this.dragPointerId = null
14
+ }
15
+
16
+ open(event) {
17
+ event.preventDefault()
18
+
19
+ const item = event.currentTarget
20
+ const index = this.itemTargets.indexOf(item)
21
+
22
+ if (index === -1) return
23
+
24
+ this.currentIndex = index
25
+ this.showCurrentImage()
26
+
27
+ this.overlayTarget.hidden = false
28
+ document.body.classList.add('image-lightbox-open')
29
+
30
+ this.overlayTarget.focus()
31
+ }
32
+
33
+ close() {
34
+ if (this.isSliding) return
35
+
36
+ this.overlayTarget.hidden = true
37
+ document.body.classList.remove('image-lightbox-open')
38
+ }
39
+
40
+ /*
41
+ closeOnBackground(event) {
42
+ if (event.target !== this.overlayTarget) return
43
+
44
+ this.close()
45
+ }*/
46
+
47
+ closeOnBackground(event) {
48
+ if (this.isDragging || this.isSliding) return
49
+
50
+ const image = this.imageTarget
51
+ const rect = image.getBoundingClientRect()
52
+
53
+ const clickedOnImage =
54
+ event.clientX >= rect.left &&
55
+ event.clientX <= rect.right &&
56
+ event.clientY >= rect.top &&
57
+ event.clientY <= rect.bottom
58
+
59
+ if (clickedOnImage) return
60
+
61
+ this.close()
62
+ }
63
+
64
+ previous(event) {
65
+ event.stopPropagation()
66
+
67
+ if (this.itemTargets.length <= 1) return
68
+
69
+ const nextIndex = (this.currentIndex - 1 + this.itemTargets.length) % this.itemTargets.length
70
+
71
+ this.slideTo(nextIndex, 'previous')
72
+ }
73
+
74
+ next(event) {
75
+ event.stopPropagation()
76
+
77
+ if (this.itemTargets.length <= 1) return
78
+
79
+ const nextIndex = (this.currentIndex + 1) % this.itemTargets.length
80
+
81
+ this.slideTo(nextIndex, 'next')
82
+ }
83
+
84
+ keydown(event) {
85
+ if (this.overlayTarget.hidden) return
86
+
87
+ switch (event.key) {
88
+ case 'Escape':
89
+ event.preventDefault()
90
+ this.close()
91
+ break
92
+
93
+ case 'ArrowLeft':
94
+ event.preventDefault()
95
+ this.previous(event)
96
+ break
97
+
98
+ case 'ArrowRight':
99
+ event.preventDefault()
100
+ this.next(event)
101
+ break
102
+ }
103
+ }
104
+
105
+ prefersReducedMotion() {
106
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
107
+ }
108
+
109
+ pointerDown(event) {
110
+ if (this.isSliding) return
111
+ if (this.itemTargets.length <= 1) return
112
+
113
+ this.isDragging = true
114
+ this.dragStartX = event.clientX
115
+ this.dragCurrentX = event.clientX
116
+ this.dragPointerId = event.pointerId
117
+
118
+ const previousIndex = (this.currentIndex - 1 + this.itemTargets.length) % this.itemTargets.length
119
+
120
+ const nextIndex = (this.currentIndex + 1) % this.itemTargets.length
121
+
122
+ this.dragPreviousImage = this.createDragImage(previousIndex, 'previous')
123
+
124
+ this.dragNextImage = this.createDragImage(nextIndex, 'next')
125
+
126
+ this.stageTarget.appendChild(this.dragPreviousImage)
127
+ this.stageTarget.appendChild(this.dragNextImage)
128
+
129
+ this.stageTarget.setPointerCapture(event.pointerId)
130
+ }
131
+
132
+ pointerMove(event) {
133
+ if (!this.isDragging) return
134
+ if (event.pointerId !== this.dragPointerId) return
135
+
136
+ this.dragCurrentX = event.clientX
137
+
138
+ const offset = this.dragCurrentX - this.dragStartX
139
+ const width = this.stageTarget.clientWidth
140
+
141
+ if (width <= 0) return
142
+
143
+ this.imageTarget.style.transform = `translateX(${offset}px)`
144
+
145
+ this.dragPreviousImage.style.transform = `translateX(${offset - width}px)`
146
+
147
+ this.dragNextImage.style.transform = `translateX(${offset + width}px)`
148
+ }
149
+
150
+ pointerCancel(event) {
151
+ if (!this.isDragging) return
152
+ if (event.pointerId !== this.dragPointerId) return
153
+
154
+ this.dragCurrentX = this.dragStartX
155
+ this.pointerUp(event)
156
+ }
157
+
158
+ pointerUp(event) {
159
+ if (!this.isDragging) return
160
+ if (event.pointerId !== this.dragPointerId) return
161
+
162
+ const offset = this.dragCurrentX - this.dragStartX
163
+ const width = this.stageTarget.clientWidth
164
+ const threshold = Math.min(width * 0.2, 140)
165
+ const reducedMotion = this.prefersReducedMotion()
166
+
167
+ this.isDragging = false
168
+
169
+ if (this.stageTarget.hasPointerCapture(event.pointerId)) {
170
+ this.stageTarget.releasePointerCapture(event.pointerId)
171
+ }
172
+
173
+ this.dragPointerId = null
174
+
175
+ const currentImage = this.imageTarget
176
+ const previousImage = this.dragPreviousImage
177
+ const nextImage = this.dragNextImage
178
+
179
+ if (Math.abs(offset) < 3) {
180
+ currentImage.style.transform = ''
181
+
182
+ previousImage?.remove()
183
+ nextImage?.remove()
184
+
185
+ this.dragPreviousImage = null
186
+ this.dragNextImage = null
187
+ this.isSliding = false
188
+
189
+ return
190
+ }
191
+
192
+ const finishDrag = (newIndex, activeImage) => {
193
+ currentImage.remove()
194
+
195
+ if (activeImage !== previousImage) {
196
+ previousImage?.remove()
197
+ }
198
+
199
+ if (activeImage !== nextImage) {
200
+ nextImage?.remove()
201
+ }
202
+
203
+ activeImage.classList.remove('image-lightbox__image--dragging')
204
+ activeImage.style.transition = ''
205
+ activeImage.style.transform = ''
206
+ activeImage.dataset.imageLightboxTarget = 'image'
207
+
208
+ this.currentIndex = newIndex
209
+ this.updateInformation()
210
+
211
+ this.dragPreviousImage = null
212
+ this.dragNextImage = null
213
+ this.isSliding = false
214
+ }
215
+
216
+ const resetDrag = () => {
217
+ if (reducedMotion) {
218
+ currentImage.style.transform = ''
219
+
220
+ previousImage?.remove()
221
+ nextImage?.remove()
222
+
223
+ this.dragPreviousImage = null
224
+ this.dragNextImage = null
225
+ this.isSliding = false
226
+
227
+ return
228
+ }
229
+
230
+ currentImage.style.transition = 'transform 0.28s cubic-bezier(0.4, 0, 0.2, 1)'
231
+
232
+ previousImage.style.transition = 'transform 0.28s cubic-bezier(0.4, 0, 0.2, 1)'
233
+
234
+ nextImage.style.transition = 'transform 0.28s cubic-bezier(0.4, 0, 0.2, 1)'
235
+
236
+ requestAnimationFrame(() => {
237
+ currentImage.style.transform = 'translateX(0)'
238
+ previousImage.style.transform = 'translateX(-100%)'
239
+ nextImage.style.transform = 'translateX(100%)'
240
+ })
241
+
242
+ currentImage.addEventListener(
243
+ 'transitionend',
244
+ () => {
245
+ currentImage.style.transition = ''
246
+ currentImage.style.transform = ''
247
+
248
+ previousImage.remove()
249
+ nextImage.remove()
250
+
251
+ this.dragPreviousImage = null
252
+ this.dragNextImage = null
253
+ this.isSliding = false
254
+ },
255
+ { once: true }
256
+ )
257
+ }
258
+
259
+ if (Math.abs(offset) < threshold) {
260
+ this.isSliding = true
261
+ resetDrag()
262
+ return
263
+ }
264
+
265
+ this.isSliding = true
266
+
267
+ const newIndex =
268
+ offset < 0
269
+ ? (this.currentIndex + 1) % this.itemTargets.length
270
+ : (this.currentIndex - 1 + this.itemTargets.length) % this.itemTargets.length
271
+
272
+ const activeImage = offset < 0 ? nextImage : previousImage
273
+
274
+ if (reducedMotion) {
275
+ finishDrag(newIndex, activeImage)
276
+ return
277
+ }
278
+
279
+ currentImage.style.transition = 'transform 0.28s cubic-bezier(0.4, 0, 0.2, 1)'
280
+
281
+ previousImage.style.transition = 'transform 0.28s cubic-bezier(0.4, 0, 0.2, 1)'
282
+
283
+ nextImage.style.transition = 'transform 0.28s cubic-bezier(0.4, 0, 0.2, 1)'
284
+
285
+ if (offset < 0) {
286
+ requestAnimationFrame(() => {
287
+ currentImage.style.transform = 'translateX(-100%)'
288
+ nextImage.style.transform = 'translateX(0)'
289
+ })
290
+
291
+ nextImage.addEventListener('transitionend', () => finishDrag(newIndex, nextImage), { once: true })
292
+ } else {
293
+ requestAnimationFrame(() => {
294
+ currentImage.style.transform = 'translateX(100%)'
295
+ previousImage.style.transform = 'translateX(0)'
296
+ })
297
+
298
+ previousImage.addEventListener('transitionend', () => finishDrag(newIndex, previousImage), { once: true })
299
+ }
300
+ }
301
+
302
+ createDragImage(index, direction) {
303
+ const item = this.itemTargets[index]
304
+ const image = document.createElement('img')
305
+
306
+ image.src = item.dataset.imageLightboxUrl
307
+ image.alt = item.dataset.imageLightboxAlt || ''
308
+
309
+ image.className = ['image-lightbox__image', 'image-lightbox__image--dragging'].join(' ')
310
+
311
+ image.style.transform = direction === 'next' ? 'translateX(100%)' : 'translateX(-100%)'
312
+
313
+ return image
314
+ }
315
+
316
+ slideTo(index, direction) {
317
+ if (this.isSliding) return
318
+
319
+ const nextItem = this.itemTargets[index]
320
+
321
+ if (!nextItem) return
322
+
323
+ this.isSliding = true
324
+
325
+ const currentImage = this.imageTarget
326
+ const nextImage = document.createElement('img')
327
+
328
+ nextImage.alt = nextItem.dataset.imageLightboxAlt || ''
329
+ nextImage.className = [
330
+ 'image-lightbox__image',
331
+ 'image-lightbox__image--sliding',
332
+ direction === 'next' ? 'image-lightbox__image--waiting-right' : 'image-lightbox__image--waiting-left'
333
+ ].join(' ')
334
+
335
+ nextImage.addEventListener(
336
+ 'load',
337
+ () => {
338
+ this.stageTarget.appendChild(nextImage)
339
+
340
+ requestAnimationFrame(() => {
341
+ requestAnimationFrame(() => {
342
+ currentImage.classList.add(
343
+ 'image-lightbox__image--sliding',
344
+ direction === 'next' ? 'image-lightbox__image--exit-left' : 'image-lightbox__image--exit-right'
345
+ )
346
+
347
+ nextImage.classList.remove('image-lightbox__image--waiting-right', 'image-lightbox__image--waiting-left')
348
+
349
+ nextImage.classList.add('image-lightbox__image--active')
350
+ })
351
+ })
352
+
353
+ const finishSlide = () => {
354
+ if (!currentImage.isConnected) return
355
+
356
+ currentImage.remove()
357
+
358
+ nextImage.classList.remove('image-lightbox__image--sliding', 'image-lightbox__image--active')
359
+
360
+ nextImage.dataset.imageLightboxTarget = 'image'
361
+
362
+ this.currentIndex = index
363
+ this.updateInformation()
364
+
365
+ this.isSliding = false
366
+ }
367
+
368
+ if (this.prefersReducedMotion()) {
369
+ finishSlide()
370
+ } else {
371
+ nextImage.addEventListener('transitionend', finishSlide, { once: true })
372
+ }
373
+ },
374
+ { once: true }
375
+ )
376
+
377
+ nextImage.src = nextItem.dataset.imageLightboxUrl
378
+ }
379
+
380
+ showCurrentImage() {
381
+ const item = this.itemTargets[this.currentIndex]
382
+
383
+ if (!item) return
384
+
385
+ this.imageTarget.src = item.dataset.imageLightboxUrl
386
+ this.imageTarget.alt = item.dataset.imageLightboxAlt || ''
387
+
388
+ this.updateInformation()
389
+ }
390
+
391
+ updateInformation() {
392
+ const item = this.itemTargets[this.currentIndex]
393
+
394
+ if (!item) return
395
+
396
+ if (this.hasCaptionTarget) {
397
+ this.captionTarget.textContent = item.dataset.imageLightboxAlt || ''
398
+ }
399
+
400
+ if (this.hasCounterTarget) {
401
+ this.counterTarget.textContent = `${this.currentIndex + 1} von ${this.itemTargets.length}`
402
+ }
403
+
404
+ const hasMultipleImages = this.itemTargets.length > 1
405
+
406
+ if (this.hasPreviousButtonTarget) {
407
+ this.previousButtonTarget.hidden = !hasMultipleImages
408
+ }
409
+
410
+ if (this.hasNextButtonTarget) {
411
+ this.nextButtonTarget.hidden = !hasMultipleImages
412
+ }
413
+ }
414
+ }
@@ -0,0 +1,85 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ['input', 'container', 'image']
5
+
6
+ preview() {
7
+ const file = this.inputTarget.files[0]
8
+
9
+ if (!file) {
10
+ this.clear()
11
+ return
12
+ }
13
+
14
+ if (!file.type.startsWith('image/')) {
15
+ this.clear()
16
+ return
17
+ }
18
+
19
+ const reader = new FileReader()
20
+
21
+ reader.onload = (event) => {
22
+ this.imageTarget.src = event.target.result
23
+ this.containerTarget.hidden = false
24
+ }
25
+
26
+ reader.readAsDataURL(file)
27
+ }
28
+
29
+ previewMultiple() {
30
+ const files = Array.from(this.inputTarget.files)
31
+
32
+ this.element.querySelectorAll('[data-image-preview-new]').forEach((image) => image.remove())
33
+
34
+ if (files.length === 0) {
35
+ return
36
+ }
37
+
38
+ files
39
+ .filter((file) => file.type.startsWith('image/'))
40
+ .forEach((file) => {
41
+ const reader = new FileReader()
42
+
43
+ reader.onload = (event) => {
44
+ const image = document.createElement('img')
45
+
46
+ image.src = event.target.result
47
+ image.alt = file.name
48
+ image.className = 'form-image-preview__image'
49
+ image.dataset.imagePreviewNew = ''
50
+
51
+ this.containerTarget.appendChild(image)
52
+ this.containerTarget.hidden = false
53
+ }
54
+
55
+ reader.readAsDataURL(file)
56
+ })
57
+ }
58
+
59
+ toggleRemoval(event) {
60
+ const item = event.currentTarget
61
+
62
+ const checkbox = item.querySelector('[data-image-preview-target="removeCheckbox"]')
63
+
64
+ if (!checkbox) return
65
+
66
+ checkbox.checked = !checkbox.checked
67
+
68
+ item.classList.toggle('form-image-preview__item--removed', checkbox.checked)
69
+
70
+ item.setAttribute('aria-pressed', checkbox.checked.toString())
71
+ }
72
+
73
+ toggleRemovalWithKeyboard(event) {
74
+ if (event.key !== 'Enter' && event.key !== ' ') return
75
+
76
+ event.preventDefault()
77
+
78
+ this.toggleRemoval(event)
79
+ }
80
+
81
+ clear() {
82
+ this.imageTarget.removeAttribute('src')
83
+ this.containerTarget.hidden = true
84
+ }
85
+ }
@@ -0,0 +1,25 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ['menu', 'toggle']
5
+
6
+ toggle() {
7
+ const isOpen = this.element.classList.contains('navigation--open')
8
+
9
+ if (isOpen) {
10
+ this.close()
11
+ } else {
12
+ this.open()
13
+ }
14
+ }
15
+
16
+ open() {
17
+ this.element.classList.add('navigation--open')
18
+ this.toggleTarget.setAttribute('aria-expanded', 'true')
19
+ }
20
+
21
+ close() {
22
+ this.element.classList.remove('navigation--open')
23
+ this.toggleTarget.setAttribute('aria-expanded', 'false')
24
+ }
25
+ }
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NavigationHelper
4
+ def navigation_link_to(name, path, controller: nil, action: nil)
5
+ classes = ["navigation__link"]
6
+
7
+ active =
8
+ if controller && action
9
+ controller_name == controller.to_s &&
10
+ action_name == action.to_s
11
+ elsif controller
12
+ controller_name == controller.to_s
13
+ else
14
+ current_page?(path)
15
+ end
16
+
17
+ classes << "navigation__link--active" if active
18
+
19
+ link_to(
20
+ name,
21
+ path,
22
+ class: classes,
23
+ data: { action: "click->navigation#close" }
24
+ )
25
+ end
26
+ end
@@ -0,0 +1,35 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ['title']
5
+
6
+ connect() {
7
+ this.reset()
8
+ }
9
+
10
+ toggle(event) {
11
+ if (window.matchMedia('(min-width: 78rem)').matches) return
12
+
13
+ const title = event.currentTarget
14
+ const item = title.closest('.b4um-sitemap__item')
15
+
16
+ if (!item) return
17
+
18
+ const isOpen = title.getAttribute('aria-expanded') === 'true'
19
+
20
+ title.setAttribute('aria-expanded', String(!isOpen))
21
+ item.classList.toggle('is-open', !isOpen)
22
+ }
23
+
24
+ reset() {
25
+ const isDesktop = window.matchMedia('(min-width: 78rem)').matches
26
+
27
+ this.titleTargets.forEach((title) => {
28
+ title.setAttribute('aria-expanded', String(isDesktop))
29
+ })
30
+
31
+ this.element.querySelectorAll('.b4um-sitemap__item.is-open').forEach((item) => {
32
+ item.classList.remove('is-open')
33
+ })
34
+ }
35
+ }
@@ -0,0 +1,122 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ const STORAGE_KEY = 'b4um-theme'
4
+ const DARK_MEDIA_QUERY = '(prefers-color-scheme: dark)'
5
+ const THEMES = ['system', 'light', 'dark']
6
+
7
+ export default class extends Controller {
8
+ static targets = ['menu', 'toggle', 'icon', 'option']
9
+
10
+ connect() {
11
+ this.systemTheme = window.matchMedia(DARK_MEDIA_QUERY)
12
+
13
+ this.handleSystemThemeChange = this.handleSystemThemeChange.bind(this)
14
+ this.handleOutsideClick = this.handleOutsideClick.bind(this)
15
+ this.handleKeydown = this.handleKeydown.bind(this)
16
+
17
+ this.systemTheme.addEventListener('change', this.handleSystemThemeChange)
18
+ document.addEventListener('click', this.handleOutsideClick)
19
+ document.addEventListener('keydown', this.handleKeydown)
20
+
21
+ this.applyTheme()
22
+ this.close()
23
+ }
24
+
25
+ disconnect() {
26
+ this.systemTheme?.removeEventListener('change', this.handleSystemThemeChange)
27
+
28
+ document.removeEventListener('click', this.handleOutsideClick)
29
+ document.removeEventListener('keydown', this.handleKeydown)
30
+ }
31
+
32
+ toggle(event) {
33
+ event.stopPropagation()
34
+
35
+ if (this.menuTarget.hidden) {
36
+ this.open()
37
+ } else {
38
+ this.close()
39
+ }
40
+ }
41
+
42
+ select(event) {
43
+ const theme = event.currentTarget.dataset.themeValue
44
+
45
+ if (!THEMES.includes(theme)) return
46
+
47
+ if (theme === 'system') {
48
+ localStorage.removeItem(STORAGE_KEY)
49
+ } else {
50
+ localStorage.setItem(STORAGE_KEY, theme)
51
+ }
52
+
53
+ this.applyTheme()
54
+ this.close()
55
+ }
56
+
57
+ open() {
58
+ this.menuTarget.hidden = false
59
+ this.toggleTarget.setAttribute('aria-expanded', 'true')
60
+ }
61
+
62
+ close() {
63
+ this.menuTarget.hidden = true
64
+ this.toggleTarget.setAttribute('aria-expanded', 'false')
65
+ }
66
+
67
+ applyTheme() {
68
+ const preference = this.preference
69
+ const resolvedTheme = this.resolveTheme(preference)
70
+
71
+ document.documentElement.dataset.b4umTheme = resolvedTheme
72
+
73
+ this.updateOptions(preference)
74
+ this.updateIcon(resolvedTheme)
75
+ }
76
+
77
+ get preference() {
78
+ const storedTheme = localStorage.getItem(STORAGE_KEY)
79
+
80
+ return THEMES.includes(storedTheme) ? storedTheme : 'system'
81
+ }
82
+
83
+ resolveTheme(preference) {
84
+ if (preference === 'dark') return 'dark'
85
+ if (preference === 'light') return 'light'
86
+
87
+ return this.systemTheme.matches ? 'dark' : 'light'
88
+ }
89
+
90
+ updateOptions(preference) {
91
+ this.optionTargets.forEach((option) => {
92
+ const active = option.dataset.themeValue === preference
93
+
94
+ option.classList.toggle('is-active', active)
95
+ option.setAttribute('aria-checked', String(active))
96
+ })
97
+ }
98
+
99
+ updateIcon(theme) {
100
+ this.iconTarget.textContent = theme === 'dark' ? '☾' : '☀'
101
+ }
102
+
103
+ handleSystemThemeChange() {
104
+ if (this.preference !== 'system') return
105
+
106
+ this.applyTheme()
107
+ }
108
+
109
+ handleOutsideClick(event) {
110
+ if (this.element.contains(event.target)) return
111
+
112
+ this.close()
113
+ }
114
+
115
+ handleKeydown(event) {
116
+ if (event.key !== 'Escape') return
117
+ if (this.menuTarget.hidden) return
118
+
119
+ this.close()
120
+ this.toggleTarget.focus()
121
+ }
122
+ }