odt-ui-rails 2.2.4 → 2.2.6

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.
@@ -10,7 +10,6 @@ module Odt
10
10
  desc "Configures ODT UI standalone stylesheets, Tailwind theme, and RBS type signatures for Rails application"
11
11
 
12
12
  def setup_stylesheets
13
- # 1. Setup Tailwind CSS v4 Theme if Tailwind is present
14
13
  tailwind_candidates = [
15
14
  "app/assets/tailwind/application.css",
16
15
  "app/assets/stylesheets/application.tailwind.css"
@@ -29,12 +28,10 @@ module Odt
29
28
  end
30
29
  end
31
30
 
32
- # 2. Inject stylesheet_link_tag into application.html.erb layout
33
31
  layout_file = "app/views/layouts/application.html.erb"
34
32
  if File.exist?(layout_file)
35
33
  layout_content = File.read(layout_file)
36
34
  unless layout_content.include?("odt_ui")
37
- # Inject right after stylesheet_link_tag :app or in <head>
38
35
  if layout_content.include?("<%= stylesheet_link_tag :app")
39
36
  insert_into_file layout_file, "\n <%= stylesheet_link_tag \"odt_ui\", \"data-turbo-track\": \"reload\" %>", after: /<%= stylesheet_link_tag :app[^\n]*%>/
40
37
  elsif layout_content.include?("<%= stylesheet_link_tag \"application\"")
@@ -56,8 +53,10 @@ module Odt
56
53
  def copy_stimulus_controller
57
54
  if File.directory?("app/javascript/controllers") || File.directory?("app/javascript")
58
55
  empty_directory "app/javascript/controllers"
56
+ copy_file "odt_toaster_controller.js", "app/javascript/controllers/odt_toaster_controller.js"
59
57
  copy_file "odt_toast_controller.js", "app/javascript/controllers/odt_toast_controller.js"
60
58
  copy_file "odt_modal_controller.js", "app/javascript/controllers/odt_modal_controller.js"
59
+ copy_file "odt_dropdown_controller.js", "app/javascript/controllers/odt_dropdown_controller.js"
61
60
  say "Copied Stimulus controllers to app/javascript/controllers/", :green
62
61
  end
63
62
  end
@@ -0,0 +1,137 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["trigger", "content", "chevron", "hiddenInput", "text"]
5
+ static values = {
6
+ open: { type: Boolean, default: false },
7
+ closeOnClickOutside: { type: Boolean, default: true },
8
+ closeOnSelect: { type: Boolean, default: true }
9
+ }
10
+
11
+ connect() {
12
+ this.boundPointerDown = (event) => {
13
+ if (!this.openValue || !this.closeOnClickOutsideValue) return
14
+ if (!this.element.contains(event.target)) {
15
+ this.close()
16
+ }
17
+ }
18
+ document.addEventListener("pointerdown", this.boundPointerDown)
19
+
20
+ this.boundKeyDown = (event) => {
21
+ if (!this.openValue) return
22
+ if (event.key === "Escape") {
23
+ event.stopPropagation()
24
+ this.close()
25
+ if (this.hasTriggerTarget) this.triggerTarget.focus()
26
+ }
27
+ }
28
+ window.addEventListener("keydown", this.boundKeyDown)
29
+
30
+ if (this.openValue) {
31
+ this.open()
32
+ }
33
+ }
34
+
35
+ disconnect() {
36
+ if (this.boundPointerDown) {
37
+ document.removeEventListener("pointerdown", this.boundPointerDown)
38
+ }
39
+ if (this.boundKeyDown) {
40
+ window.removeEventListener("keydown", this.boundKeyDown)
41
+ }
42
+ }
43
+
44
+ toggle(event) {
45
+ if (event) event.preventDefault()
46
+ if (this.openValue) {
47
+ this.close()
48
+ } else {
49
+ this.open()
50
+ }
51
+ }
52
+
53
+ open(event) {
54
+ if (event) event.preventDefault()
55
+ this.openValue = true
56
+
57
+ if (this.hasContentTarget) {
58
+ this.contentTarget.classList.remove("hidden")
59
+ this.contentTarget.classList.remove("odt-dropdown__content--exiting")
60
+ }
61
+
62
+ if (this.hasTriggerTarget) {
63
+ this.triggerTarget.classList.add("odt-dropdown__trigger--open")
64
+ this.triggerTarget.setAttribute("aria-expanded", "true")
65
+ }
66
+
67
+ if (this.hasChevronTarget) {
68
+ this.chevronTarget.classList.add("odt-dropdown__chevron--open")
69
+ }
70
+
71
+ this.element.dispatchEvent(new CustomEvent("odt-dropdown:opened", { bubbles: true }))
72
+ }
73
+
74
+ close(event) {
75
+ if (event) event.preventDefault()
76
+ this.openValue = false
77
+
78
+ if (this.hasTriggerTarget) {
79
+ this.triggerTarget.classList.remove("odt-dropdown__trigger--open")
80
+ this.triggerTarget.setAttribute("aria-expanded", "false")
81
+ }
82
+
83
+ if (this.hasChevronTarget) {
84
+ this.chevronTarget.classList.remove("odt-dropdown__chevron--open")
85
+ }
86
+
87
+ if (this.hasContentTarget) {
88
+ this.contentTarget.classList.add("odt-dropdown__content--exiting")
89
+ setTimeout(() => {
90
+ this.contentTarget.classList.add("hidden")
91
+ this.contentTarget.classList.remove("odt-dropdown__content--exiting")
92
+ this.element.dispatchEvent(new CustomEvent("odt-dropdown:closed", { bubbles: true }))
93
+ }, 110)
94
+ }
95
+ }
96
+
97
+ select(event) {
98
+ const item = event.currentTarget
99
+ const value = item.dataset.value
100
+ const label = item.dataset.label || item.textContent.trim()
101
+
102
+ if (this.hasHiddenInputTarget && value !== undefined) {
103
+ this.hiddenInputTarget.value = value
104
+ this.hiddenInputTarget.dispatchEvent(new Event("change", { bubbles: true }))
105
+ }
106
+
107
+ if (this.hasTextTarget && label) {
108
+ this.textTarget.textContent = label
109
+ this.textTarget.classList.remove("odt-dropdown__placeholder")
110
+ }
111
+
112
+ if (this.closeOnSelectValue) {
113
+ this.close()
114
+ }
115
+ }
116
+
117
+ navigate(event) {
118
+ if (!this.hasContentTarget || !this.openValue) return
119
+
120
+ const items = Array.from(
121
+ this.contentTarget.querySelectorAll('[role="menuitem"]:not([disabled]):not(.odt-dropdown__item--disabled)')
122
+ )
123
+ if (!items.length) return
124
+
125
+ const activeIndex = items.indexOf(document.activeElement)
126
+
127
+ if (event.key === "ArrowDown") {
128
+ event.preventDefault()
129
+ const nextIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0
130
+ items[nextIndex]?.focus()
131
+ } else if (event.key === "ArrowUp") {
132
+ event.preventDefault()
133
+ const prevIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1
134
+ items[prevIndex]?.focus()
135
+ }
136
+ }
137
+ }
@@ -8,6 +8,24 @@ export default class extends Controller {
8
8
  }
9
9
 
10
10
  connect() {
11
+ if (this.element.id) {
12
+ this.boundTriggerHandler = (event) => {
13
+ const trigger = event.target.closest(`[data-odt-modal-target-id="${this.element.id}"], [data-modal-target="${this.element.id}"], [href="#${this.element.id}"]`)
14
+ if (trigger) {
15
+ event.preventDefault()
16
+ this.open()
17
+ }
18
+ }
19
+ document.addEventListener("click", this.boundTriggerHandler)
20
+ }
21
+
22
+ this.boundTurboSubmitHandler = (event) => {
23
+ if (event.detail?.success && this.element.contains(event.target)) {
24
+ this.close()
25
+ }
26
+ }
27
+ this.element.addEventListener("turbo:submit-end", this.boundTurboSubmitHandler)
28
+
11
29
  if (this.openValue) {
12
30
  this.open()
13
31
  }
@@ -57,5 +75,11 @@ export default class extends Controller {
57
75
 
58
76
  disconnect() {
59
77
  document.body.style.overflow = ""
78
+ if (this.boundTriggerHandler) {
79
+ document.removeEventListener("click", this.boundTriggerHandler)
80
+ }
81
+ if (this.boundTurboSubmitHandler) {
82
+ this.element.removeEventListener("turbo:submit-end", this.boundTurboSubmitHandler)
83
+ }
60
84
  }
61
85
  }
@@ -7,6 +7,7 @@ export default class extends Controller {
7
7
 
8
8
  connect() {
9
9
  this.startTimer()
10
+ this.notifyToaster()
10
11
  }
11
12
 
12
13
  startTimer() {
@@ -26,11 +27,22 @@ export default class extends Controller {
26
27
  this.startTimer()
27
28
  }
28
29
 
30
+ notifyToaster() {
31
+ const toaster = this.element.closest("[data-controller~='odt-toaster']")
32
+ if (toaster) {
33
+ const toasterController = this.application.getControllerForElementAndIdentifier(toaster, "odt-toaster")
34
+ toasterController?.updateStack()
35
+ }
36
+ }
37
+
29
38
  dismiss() {
30
39
  this.pauseTimer()
31
40
  this.element.classList.add("odt-toast--exiting")
41
+ this.notifyToaster()
42
+
32
43
  setTimeout(() => {
33
44
  this.element.remove()
45
+ this.notifyToaster()
34
46
  }, 250)
35
47
  }
36
48
 
@@ -0,0 +1,195 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ position: { type: String, default: "top-right" },
6
+ expand: { type: Boolean, default: true }
7
+ }
8
+
9
+ connect() {
10
+ this.isHovered = false
11
+ this.leaveTimeout = null
12
+ this.mutationObserver = new MutationObserver(() => this.handleMutations())
13
+ this.mutationObserver.observe(this.element, { childList: true })
14
+ this.handleMutations()
15
+
16
+ this.boundTriggerHandler = (event) => {
17
+ const trigger = event.target.closest("[data-odt-toast-message], [data-odt-toast-title]")
18
+ if (trigger) {
19
+ event.preventDefault()
20
+ this.show({
21
+ title: trigger.dataset.odtToastTitle,
22
+ message: trigger.dataset.odtToastMessage,
23
+ type: trigger.dataset.odtToastType || "default",
24
+ variant: trigger.dataset.odtToastVariant || "elevated",
25
+ duration: Number(trigger.dataset.odtToastDuration) || 4000
26
+ })
27
+ }
28
+ }
29
+ document.addEventListener("click", this.boundTriggerHandler)
30
+
31
+ window.odtToast = {
32
+ show: (options) => this.show(options),
33
+ success: (title, message) => this.show({ title, message, type: "success" }),
34
+ error: (title, message) => this.show({ title, message, type: "danger" }),
35
+ warning: (title, message) => this.show({ title, message, type: "warning" }),
36
+ info: (title, message) => this.show({ title, message, type: "info" })
37
+ }
38
+ }
39
+
40
+ disconnect() {
41
+ if (this.mutationObserver) {
42
+ this.mutationObserver.disconnect()
43
+ }
44
+ if (this.leaveTimeout) {
45
+ clearTimeout(this.leaveTimeout)
46
+ }
47
+ if (this.boundTriggerHandler) {
48
+ document.removeEventListener("click", this.boundTriggerHandler)
49
+ }
50
+ if (window.odtToast) {
51
+ delete window.odtToast
52
+ }
53
+ }
54
+
55
+ show(eventOrOptions = {}) {
56
+ const options = eventOrOptions?.params || eventOrOptions || {}
57
+ const title = options.title || ""
58
+ const message = options.message || ""
59
+ const type = options.type || "default"
60
+ const variant = options.variant || "elevated"
61
+ const duration = options.duration !== undefined ? Number(options.duration) : 4000
62
+
63
+ const toast = document.createElement("div")
64
+ toast.className = `odt-toast odt-toast--variant-${variant}`
65
+ toast.setAttribute("role", "status")
66
+ toast.setAttribute("data-controller", "odt-toast")
67
+ toast.setAttribute("data-odt-toast-duration-value", duration.toString())
68
+
69
+ const parts = []
70
+
71
+ const icons = {
72
+ success: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>',
73
+ info: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
74
+ warning: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
75
+ danger: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
76
+ error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
77
+ loading: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>'
78
+ }
79
+
80
+ if (icons[type]) {
81
+ parts.push(`<span class="odt-toast__icon odt-toast__icon--${type}">${icons[type]}</span>`)
82
+ }
83
+
84
+ let bodyHtml = '<div class="odt-toast__body">'
85
+ if (title) {
86
+ bodyHtml += `<div class="odt-toast__title">${title}</div>`
87
+ }
88
+ if (message) {
89
+ bodyHtml += `<div class="odt-toast__description">${message}</div>`
90
+ }
91
+ bodyHtml += '</div>'
92
+ parts.push(bodyHtml)
93
+
94
+ parts.push(`
95
+ <button type="button" class="odt-toast__close" aria-label="Close" data-action="click->odt-toast#dismiss">
96
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
97
+ <line x1="18" y1="6" x2="6" y2="18"></line>
98
+ <line x1="6" y1="6" x2="18" y2="18"></line>
99
+ </svg>
100
+ </button>
101
+ `)
102
+
103
+ toast.innerHTML = parts.join("")
104
+ this.element.appendChild(toast)
105
+ }
106
+
107
+ handleMutations() {
108
+ const unmountedToasts = this.element.querySelectorAll(":scope > .odt-toast:not([data-toast-mounted]):not(.odt-toast--exiting)")
109
+ const isTop = this.positionValue.startsWith("top")
110
+
111
+ unmountedToasts.forEach((toast) => {
112
+ toast.style.transform = `translate3d(0, ${isTop ? -24 : 24}px, 0) scale(0.92)`
113
+ toast.style.opacity = "0"
114
+ toast.style.pointerEvents = "none"
115
+ toast.dataset.toastMounted = "pending"
116
+ })
117
+
118
+ requestAnimationFrame(() => {
119
+ requestAnimationFrame(() => {
120
+ unmountedToasts.forEach((toast) => {
121
+ toast.dataset.toastMounted = "true"
122
+ })
123
+ this.updateStack()
124
+ })
125
+ })
126
+ }
127
+
128
+ hover() {
129
+ if (!this.expandValue) return
130
+ if (this.leaveTimeout) {
131
+ clearTimeout(this.leaveTimeout)
132
+ this.leaveTimeout = null
133
+ }
134
+ if (!this.isHovered) {
135
+ this.isHovered = true
136
+ this.element.style.pointerEvents = "auto"
137
+ this.updateStack()
138
+ }
139
+ }
140
+
141
+ leave() {
142
+ if (!this.expandValue) return
143
+ if (this.leaveTimeout) {
144
+ clearTimeout(this.leaveTimeout)
145
+ }
146
+ this.leaveTimeout = setTimeout(() => {
147
+ this.isHovered = false
148
+ this.element.style.pointerEvents = "none"
149
+ this.updateStack()
150
+ }, 180)
151
+ }
152
+
153
+ get toasts() {
154
+ return Array.from(this.element.querySelectorAll(":scope > .odt-toast:not(.odt-toast--exiting)"))
155
+ }
156
+
157
+ updateStack() {
158
+ const toasts = this.toasts.reverse()
159
+ const isTop = this.positionValue.startsWith("top")
160
+ const GAP = 12
161
+
162
+ let accumulatedOffset = 0
163
+
164
+ toasts.forEach((toast, index) => {
165
+ if (toast.dataset.toastMounted === "pending") return
166
+
167
+ const isVisible = index < 3 || this.isHovered
168
+ const zIndex = 50 - index
169
+ const stackedBaseY = isTop ? index * 14 : -(index * 14)
170
+ const stackedBaseScale = Math.max(0.84, 1 - index * 0.06)
171
+
172
+ let targetY = 0
173
+ let targetScale = 1
174
+ let targetOpacity = 1
175
+
176
+ if (this.isHovered) {
177
+ targetY = isTop ? accumulatedOffset : -accumulatedOffset
178
+ targetScale = 1
179
+ targetOpacity = isVisible ? 1 : 0
180
+ } else {
181
+ targetY = stackedBaseY
182
+ targetScale = stackedBaseScale
183
+ targetOpacity = index === 0 ? 1 : index === 1 ? 0.92 : index === 2 ? 0.75 : 0
184
+ }
185
+
186
+ toast.style.transform = `translate3d(0, ${targetY}px, 0) scale(${targetScale})`
187
+ toast.style.opacity = targetOpacity
188
+ toast.style.zIndex = zIndex
189
+ toast.style.pointerEvents = isVisible ? "auto" : "none"
190
+
191
+ const height = toast.offsetHeight || 64
192
+ accumulatedOffset += height + GAP
193
+ })
194
+ }
195
+ }