ruby_ui_admin 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 (182) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +165 -0
  4. data/app/assets/stylesheets/ruby_ui_admin/application.tailwind.css +111 -0
  5. data/app/components/ruby_ui_admin/ui/base.rb +26 -0
  6. data/app/components/ruby_ui_admin/ui/icon.rb +79 -0
  7. data/app/components/ruby_ui_admin/ui/native_select.rb +37 -0
  8. data/app/components/ruby_ui_admin/ui/native_select_group.rb +18 -0
  9. data/app/components/ruby_ui_admin/ui/native_select_icon.rb +42 -0
  10. data/app/components/ruby_ui_admin/ui/native_select_option.rb +18 -0
  11. data/app/components/ruby_ui_admin/ui/pagination.rb +62 -0
  12. data/app/components/ruby_ui_admin/ui/pagination_content.rb +18 -0
  13. data/app/components/ruby_ui_admin/ui/pagination_ellipsis.rb +45 -0
  14. data/app/components/ruby_ui_admin/ui/pagination_item.rb +32 -0
  15. data/app/components/ruby_ui_admin/ui/select.rb +29 -0
  16. data/app/components/ruby_ui_admin/ui/toast_action.rb +30 -0
  17. data/app/components/ruby_ui_admin/ui/toast_cancel.rb +30 -0
  18. data/app/components/ruby_ui_admin/ui/toast_close.rb +43 -0
  19. data/app/components/ruby_ui_admin/ui/toast_description.rb +21 -0
  20. data/app/components/ruby_ui_admin/ui/toast_icon.rb +69 -0
  21. data/app/components/ruby_ui_admin/ui/toast_item.rb +75 -0
  22. data/app/components/ruby_ui_admin/ui/toast_region.rb +147 -0
  23. data/app/components/ruby_ui_admin/ui/toast_title.rb +21 -0
  24. data/app/components/ruby_ui_admin/ui.rb +12 -0
  25. data/app/components/ruby_ui_admin/views/action.rb +37 -0
  26. data/app/components/ruby_ui_admin/views/action_form.rb +104 -0
  27. data/app/components/ruby_ui_admin/views/action_trigger.rb +148 -0
  28. data/app/components/ruby_ui_admin/views/actions_menu.rb +34 -0
  29. data/app/components/ruby_ui_admin/views/base.rb +301 -0
  30. data/app/components/ruby_ui_admin/views/card_component.rb +69 -0
  31. data/app/components/ruby_ui_admin/views/dashboard.rb +40 -0
  32. data/app/components/ruby_ui_admin/views/docs.rb +91 -0
  33. data/app/components/ruby_ui_admin/views/field_input.rb +156 -0
  34. data/app/components/ruby_ui_admin/views/field_value.rb +301 -0
  35. data/app/components/ruby_ui_admin/views/filter_bar.rb +111 -0
  36. data/app/components/ruby_ui_admin/views/form.rb +151 -0
  37. data/app/components/ruby_ui_admin/views/home.rb +35 -0
  38. data/app/components/ruby_ui_admin/views/index.rb +324 -0
  39. data/app/components/ruby_ui_admin/views/path_helpers.rb +36 -0
  40. data/app/components/ruby_ui_admin/views/rails_helpers.rb +15 -0
  41. data/app/components/ruby_ui_admin/views/show.rb +77 -0
  42. data/app/components/ruby_ui_admin/views/show_fields.rb +37 -0
  43. data/app/components/ruby_ui_admin/views/show_tab.rb +58 -0
  44. data/app/components/ruby_ui_admin/views/structure_renderer.rb +148 -0
  45. data/app/components/ruby_ui_admin/views/translation.rb +12 -0
  46. data/app/controllers/ruby_ui_admin/actions_controller.rb +162 -0
  47. data/app/controllers/ruby_ui_admin/application_controller.rb +81 -0
  48. data/app/controllers/ruby_ui_admin/dashboards_controller.rb +12 -0
  49. data/app/controllers/ruby_ui_admin/docs_controller.rb +166 -0
  50. data/app/controllers/ruby_ui_admin/home_controller.rb +18 -0
  51. data/app/controllers/ruby_ui_admin/resources_controller.rb +259 -0
  52. data/config/locales/ruby_ui_admin.en.yml +59 -0
  53. data/config/locales/ruby_ui_admin.pt-BR.yml +59 -0
  54. data/config/routes/dynamic_routes.rb +11 -0
  55. data/config/routes.rb +23 -0
  56. data/docs/README.md +53 -0
  57. data/docs/actions/overview.md +112 -0
  58. data/docs/authorization/action-policy.md +72 -0
  59. data/docs/authorization/adapters.md +101 -0
  60. data/docs/authorization/explicit-mode.md +45 -0
  61. data/docs/authorization/field-authorization.md +53 -0
  62. data/docs/authorization/policies.md +62 -0
  63. data/docs/authorization/scopes.md +48 -0
  64. data/docs/customization/controllers.md +62 -0
  65. data/docs/customization/ejecting.md +55 -0
  66. data/docs/customization/javascript.md +161 -0
  67. data/docs/customization/theming-rubyui.md +80 -0
  68. data/docs/dashboards/overview.md +65 -0
  69. data/docs/fields/overview.md +94 -0
  70. data/docs/filters/overview.md +73 -0
  71. data/docs/generators-and-tasks/overview.md +42 -0
  72. data/docs/getting-started/authentication.md +98 -0
  73. data/docs/getting-started/configuration.md +174 -0
  74. data/docs/getting-started/installation.md +201 -0
  75. data/docs/getting-started/internationalization.md +50 -0
  76. data/docs/getting-started/practical-guide.md +310 -0
  77. data/docs/resources/associations.md +66 -0
  78. data/docs/resources/index-customization.md +68 -0
  79. data/docs/resources/overview.md +50 -0
  80. data/docs/resources/scopes.md +54 -0
  81. data/docs/resources/tabs-panels.md +65 -0
  82. data/lib/generators/ruby_ui_admin/action/action_generator.rb +21 -0
  83. data/lib/generators/ruby_ui_admin/action/templates/action.rb.tt +24 -0
  84. data/lib/generators/ruby_ui_admin/assets/assets_generator.rb +71 -0
  85. data/lib/generators/ruby_ui_admin/card/card_generator.rb +28 -0
  86. data/lib/generators/ruby_ui_admin/card/templates/chart_card.rb.tt +14 -0
  87. data/lib/generators/ruby_ui_admin/card/templates/metric_card.rb.tt +14 -0
  88. data/lib/generators/ruby_ui_admin/card/templates/partial_card.rb.tt +13 -0
  89. data/lib/generators/ruby_ui_admin/components/components_generator.rb +43 -0
  90. data/lib/generators/ruby_ui_admin/controller/controller_generator.rb +30 -0
  91. data/lib/generators/ruby_ui_admin/controller/templates/controller.rb.tt +24 -0
  92. data/lib/generators/ruby_ui_admin/dashboard/dashboard_generator.rb +18 -0
  93. data/lib/generators/ruby_ui_admin/dashboard/templates/dashboard.rb.tt +14 -0
  94. data/lib/generators/ruby_ui_admin/eject/eject_generator.rb +55 -0
  95. data/lib/generators/ruby_ui_admin/filter/filter_generator.rb +28 -0
  96. data/lib/generators/ruby_ui_admin/filter/templates/boolean_filter.rb.tt +13 -0
  97. data/lib/generators/ruby_ui_admin/filter/templates/select_filter.rb.tt +19 -0
  98. data/lib/generators/ruby_ui_admin/filter/templates/text_filter.rb.tt +13 -0
  99. data/lib/generators/ruby_ui_admin/install/install_generator.rb +62 -0
  100. data/lib/generators/ruby_ui_admin/install/templates/initializer.rb.tt +41 -0
  101. data/lib/generators/ruby_ui_admin/locales/locales_generator.rb +22 -0
  102. data/lib/generators/ruby_ui_admin/policy/policy_generator.rb +18 -0
  103. data/lib/generators/ruby_ui_admin/policy/templates/policy.rb.tt +24 -0
  104. data/lib/generators/ruby_ui_admin/resource/resource_generator.rb +84 -0
  105. data/lib/generators/ruby_ui_admin/resource/templates/resource.rb.tt +15 -0
  106. data/lib/generators/ruby_ui_admin/scope/scope_generator.rb +18 -0
  107. data/lib/generators/ruby_ui_admin/scope/templates/scope.rb.tt +11 -0
  108. data/lib/ruby_ui_admin/authorization/action_policy_adapter.rb +69 -0
  109. data/lib/ruby_ui_admin/authorization/adapter.rb +80 -0
  110. data/lib/ruby_ui_admin/authorization/can_can_can_adapter.rb +70 -0
  111. data/lib/ruby_ui_admin/authorization/pundit_adapter.rb +86 -0
  112. data/lib/ruby_ui_admin/authorization.rb +40 -0
  113. data/lib/ruby_ui_admin/base_action.rb +211 -0
  114. data/lib/ruby_ui_admin/base_dashboard.rb +70 -0
  115. data/lib/ruby_ui_admin/base_policy.rb +38 -0
  116. data/lib/ruby_ui_admin/base_resource.rb +503 -0
  117. data/lib/ruby_ui_admin/cards/base_card.rb +59 -0
  118. data/lib/ruby_ui_admin/cards/chart_card.rb +16 -0
  119. data/lib/ruby_ui_admin/cards/metric_card.rb +22 -0
  120. data/lib/ruby_ui_admin/cards/partial_card.rb +14 -0
  121. data/lib/ruby_ui_admin/configuration.rb +177 -0
  122. data/lib/ruby_ui_admin/current.rb +20 -0
  123. data/lib/ruby_ui_admin/dashboard_manager.rb +52 -0
  124. data/lib/ruby_ui_admin/engine.rb +80 -0
  125. data/lib/ruby_ui_admin/execution_context.rb +36 -0
  126. data/lib/ruby_ui_admin/fields/association_field.rb +67 -0
  127. data/lib/ruby_ui_admin/fields/badge_field.rb +19 -0
  128. data/lib/ruby_ui_admin/fields/base_field.rb +208 -0
  129. data/lib/ruby_ui_admin/fields/belongs_to_field.rb +49 -0
  130. data/lib/ruby_ui_admin/fields/boolean_field.rb +13 -0
  131. data/lib/ruby_ui_admin/fields/boolean_group_field.rb +54 -0
  132. data/lib/ruby_ui_admin/fields/code_field.rb +18 -0
  133. data/lib/ruby_ui_admin/fields/date_field.rb +34 -0
  134. data/lib/ruby_ui_admin/fields/date_time_field.rb +37 -0
  135. data/lib/ruby_ui_admin/fields/field_manager.rb +27 -0
  136. data/lib/ruby_ui_admin/fields/file_field.rb +67 -0
  137. data/lib/ruby_ui_admin/fields/files_field.rb +62 -0
  138. data/lib/ruby_ui_admin/fields/has_and_belongs_to_many_field.rb +9 -0
  139. data/lib/ruby_ui_admin/fields/has_many_field.rb +24 -0
  140. data/lib/ruby_ui_admin/fields/has_one_field.rb +13 -0
  141. data/lib/ruby_ui_admin/fields/hidden_field.rb +14 -0
  142. data/lib/ruby_ui_admin/fields/id_field.rb +19 -0
  143. data/lib/ruby_ui_admin/fields/key_value_field.rb +41 -0
  144. data/lib/ruby_ui_admin/fields/number_field.rb +13 -0
  145. data/lib/ruby_ui_admin/fields/password_field.rb +21 -0
  146. data/lib/ruby_ui_admin/fields/record_link_field.rb +45 -0
  147. data/lib/ruby_ui_admin/fields/select_field.rb +71 -0
  148. data/lib/ruby_ui_admin/fields/status_field.rb +21 -0
  149. data/lib/ruby_ui_admin/fields/text_field.rb +14 -0
  150. data/lib/ruby_ui_admin/fields/textarea_field.rb +13 -0
  151. data/lib/ruby_ui_admin/fields/url_field.rb +21 -0
  152. data/lib/ruby_ui_admin/filters/base_filter.rb +81 -0
  153. data/lib/ruby_ui_admin/filters/boolean_filter.rb +9 -0
  154. data/lib/ruby_ui_admin/filters/multiple_select_filter.rb +11 -0
  155. data/lib/ruby_ui_admin/filters/select_filter.rb +9 -0
  156. data/lib/ruby_ui_admin/filters/text_filter.rb +9 -0
  157. data/lib/ruby_ui_admin/menu/builder.rb +94 -0
  158. data/lib/ruby_ui_admin/resource_manager.rb +82 -0
  159. data/lib/ruby_ui_admin/scopes/base_scope.rb +107 -0
  160. data/lib/ruby_ui_admin/services/authorization_service.rb +38 -0
  161. data/lib/ruby_ui_admin/structure.rb +40 -0
  162. data/lib/ruby_ui_admin/tailwind_source.rb +65 -0
  163. data/lib/ruby_ui_admin/version.rb +5 -0
  164. data/lib/ruby_ui_admin/view.rb +34 -0
  165. data/lib/ruby_ui_admin.rb +110 -0
  166. data/lib/tasks/ruby_ui_admin.rake +71 -0
  167. data/public/ruby-ui-admin-assets/application.css +2 -0
  168. data/public/ruby-ui-admin-assets/controllers/index.js +36 -0
  169. data/public/ruby-ui-admin-assets/controllers/rua--bulk-select_controller.js +19 -0
  170. data/public/ruby-ui-admin-assets/controllers/rua--confirm_controller.js +51 -0
  171. data/public/ruby-ui-admin-assets/controllers/rua--dialog_controller.js +55 -0
  172. data/public/ruby-ui-admin-assets/controllers/rua--row-link_controller.js +16 -0
  173. data/public/ruby-ui-admin-assets/controllers/rua--tabs_controller.js +36 -0
  174. data/public/ruby-ui-admin-assets/controllers/ruby_ui--combobox_controller.js +197 -0
  175. data/public/ruby-ui-admin-assets/controllers/ruby_ui--sheet_content_controller.js +7 -0
  176. data/public/ruby-ui-admin-assets/controllers/ruby_ui--sheet_controller.js +15 -0
  177. data/public/ruby-ui-admin-assets/controllers/ruby_ui--sidebar_controller.js +67 -0
  178. data/public/ruby-ui-admin-assets/controllers/ruby_ui--toast_controller.js +151 -0
  179. data/public/ruby-ui-admin-assets/controllers/ruby_ui--toaster_controller.js +314 -0
  180. data/public/ruby-ui-admin-assets/vendor/stimulus.js +2567 -0
  181. data/public/ruby-ui-admin-assets/vendor/turbo.js +7178 -0
  182. metadata +379 -0
@@ -0,0 +1,314 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const VARIANTS = ["default", "success", "error", "warning", "info", "loading"]
4
+
5
+ let streamActionRegistered = false
6
+
7
+ function registerStreamAction() {
8
+ if (streamActionRegistered) return
9
+ if (typeof window === "undefined") return
10
+ const Turbo = window.Turbo
11
+ if (!Turbo?.StreamActions) return
12
+ Turbo.StreamActions.toast = function () {
13
+ const detail = {}
14
+ for (const attr of this.attributes) {
15
+ if (attr.name === "action" || attr.name === "target" || attr.name === "targets") continue
16
+ detail[attr.name] = attr.value
17
+ }
18
+ if (detail.duration != null && detail.duration !== "") detail.duration = Number(detail.duration)
19
+ if (detail.dismissible != null) detail.dismissible = detail.dismissible !== "false"
20
+ window.dispatchEvent(new CustomEvent("ruby-ui:toast", { detail }))
21
+ }
22
+ streamActionRegistered = true
23
+ }
24
+
25
+ // Connects to data-controller="ruby-ui--toaster"
26
+ export default class extends Controller {
27
+ static targets = ["skeleton", "toast", "actionTpl", "cancelTpl", "closeTpl"]
28
+ static values = {
29
+ position: { type: String, default: "bottom-right" },
30
+ expand: { type: Boolean, default: false },
31
+ max: { type: Number, default: 3 },
32
+ duration: { type: Number, default: 4000 },
33
+ gap: { type: Number, default: 14 },
34
+ offset: { type: Number, default: 24 },
35
+ theme: { type: String, default: "system" },
36
+ richColors: { type: Boolean, default: false },
37
+ closeButton: { type: Boolean, default: false },
38
+ hotkey: { type: String, default: "alt+t" },
39
+ dir: { type: String, default: "ltr" },
40
+ }
41
+
42
+ connect() {
43
+ // `||=` so state survives a `toastTargetConnected` that fires before `connect()` —
44
+ // happens with server-rendered (flash) toasts present in the initial DOM.
45
+ this._heights ||= new Map()
46
+ this._resizeObservers ||= new WeakMap()
47
+ this._expanded = this.expandValue
48
+ this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null)
49
+ this._registerGlobalApi()
50
+ registerStreamAction()
51
+ if (!this._listEl) return
52
+
53
+ this._onPointerEnter = () => this._setExpanded(true)
54
+ this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }
55
+ this._onWindowToast = (e) => this._spawn(e.detail || {})
56
+ this._onWindowDismissAll = () => this._dismissById(null)
57
+ this._onKey = this._onKey.bind(this)
58
+
59
+ window.addEventListener("ruby-ui:toast", this._onWindowToast)
60
+ window.addEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll)
61
+ this._listEl.addEventListener("pointerenter", this._onPointerEnter)
62
+ this._listEl.addEventListener("pointerleave", this._onPointerLeave)
63
+ document.addEventListener("keydown", this._onKey)
64
+
65
+ // Position any toasts already in the DOM (server-rendered flash messages).
66
+ this._reflow()
67
+ }
68
+
69
+ disconnect() {
70
+ window.removeEventListener("ruby-ui:toast", this._onWindowToast)
71
+ window.removeEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll)
72
+ this._listEl?.removeEventListener("pointerenter", this._onPointerEnter)
73
+ this._listEl?.removeEventListener("pointerleave", this._onPointerLeave)
74
+ document.removeEventListener("keydown", this._onKey)
75
+ }
76
+
77
+ toastTargetConnected(el) {
78
+ // May fire before connect() for toasts present in the initial DOM.
79
+ this._heights ||= new Map()
80
+ this._resizeObservers ||= new WeakMap()
81
+ if (typeof ResizeObserver !== "undefined") {
82
+ const ro = new ResizeObserver(() => {
83
+ this._heights.set(el, el.offsetHeight)
84
+ this._reflow()
85
+ })
86
+ ro.observe(el)
87
+ this._resizeObservers.set(el, ro)
88
+ }
89
+ this._heights.set(el, el.offsetHeight || 64)
90
+ this._reflow()
91
+ }
92
+
93
+ toastTargetDisconnected(el) {
94
+ this._resizeObservers.get(el)?.disconnect()
95
+ this._resizeObservers.delete(el)
96
+ this._heights.delete(el)
97
+ this._reflow()
98
+ }
99
+
100
+ _spawn(detail) {
101
+ const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default"
102
+ const tpl = this._skeletonFor(variant)
103
+ if (!tpl) return null
104
+ if (detail.position) {
105
+ this.element.setAttribute("data-position", detail.position)
106
+ this.positionValue = detail.position
107
+ }
108
+ const node = tpl.content.firstElementChild.cloneNode(true)
109
+
110
+ node.id = detail.id || `toast-${this._uuid()}`
111
+ if (detail.duration != null) {
112
+ const dur = detail.duration === Infinity ? 0 : detail.duration
113
+ node.setAttribute("data-ruby-ui--toast-duration-value", String(dur))
114
+ }
115
+ if (detail.dismissible === false) {
116
+ node.setAttribute("data-ruby-ui--toast-dismissible-value", "false")
117
+ }
118
+ if (detail.className) node.className += ` ${detail.className}`
119
+
120
+ const titleEl = node.querySelector('[data-slot="title"]')
121
+ if (titleEl) titleEl.textContent = detail.title || detail.message || ""
122
+ const descEl = node.querySelector('[data-slot="description"]')
123
+ if (descEl) {
124
+ if (detail.description) descEl.textContent = detail.description
125
+ else descEl.remove()
126
+ }
127
+
128
+ if (detail.action && detail.action.label && this.hasActionTplTarget) {
129
+ const btn = this._cloneSlot(this.actionTplTarget)
130
+ btn.textContent = detail.action.label
131
+ btn.addEventListener("click", (ev) => {
132
+ try { detail.action.onClick?.(ev) } finally {
133
+ node.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
134
+ }
135
+ })
136
+ node.appendChild(btn)
137
+ }
138
+
139
+ if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {
140
+ const btn = this._cloneSlot(this.cancelTplTarget)
141
+ btn.textContent = detail.cancel.label
142
+ node.appendChild(btn)
143
+ }
144
+
145
+ if (detail.closeButton && this.hasCloseTplTarget) {
146
+ const x = this._cloneSlot(this.closeTplTarget)
147
+ node.classList.add("pr-10")
148
+ node.appendChild(x)
149
+ }
150
+
151
+ this._listEl.appendChild(node)
152
+ return node.id
153
+ }
154
+
155
+ _dismissById(id) {
156
+ if (!id) {
157
+ this.toastTargets.forEach((el) =>
158
+ el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
159
+ )
160
+ return
161
+ }
162
+ const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
163
+ if (el) el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
164
+ }
165
+
166
+ _skeletonFor(variant) {
167
+ return this.skeletonTargets.find((t) => t.dataset.variant === variant)
168
+ }
169
+
170
+ _cloneSlot(tpl) {
171
+ return tpl.content.firstElementChild.cloneNode(true)
172
+ }
173
+
174
+ _setExpanded(value) {
175
+ if (this._expanded === value) return
176
+ this._expanded = value
177
+ document.dispatchEvent(new CustomEvent(value ? "ruby-ui:toast:pause" : "ruby-ui:toast:resume"))
178
+ this._reflow()
179
+ }
180
+
181
+ _reflow() {
182
+ if (!this._listEl) return
183
+ const isBottom = this.positionValue.startsWith("bottom")
184
+ const items = this.toastTargets
185
+ const order = isBottom ? items.slice().reverse() : items.slice()
186
+ const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)
187
+ const gap = this.gapValue
188
+ const peekOffset = 16
189
+ const peekScaleStep = 0.05
190
+ const peekOpacityStep = 0.2
191
+
192
+ const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)
193
+ const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset
194
+ this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`
195
+
196
+ let acc = 0
197
+ order.forEach((el, i) => {
198
+ const visible = i < this.maxValue
199
+ let yOffset, scale, opacity
200
+
201
+ if (this._expanded) {
202
+ yOffset = acc + i * gap
203
+ scale = 1
204
+ opacity = visible ? 1 : 0
205
+ } else {
206
+ yOffset = i * peekOffset
207
+ scale = Math.max(0.85, 1 - i * peekScaleStep)
208
+ opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0
209
+ }
210
+
211
+ const sign = isBottom ? -1 : 1
212
+ const ty = sign * yOffset
213
+
214
+ el.style.setProperty("--opacity", String(opacity))
215
+ el.style.setProperty("--scale", String(scale))
216
+ el.style.setProperty("--y-offset", `${ty}px`)
217
+ el.style.transformOrigin = isBottom ? "center bottom" : "center top"
218
+ el.style.top = isBottom ? "auto" : "0"
219
+ el.style.bottom = isBottom ? "0" : "auto"
220
+ el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`
221
+ el.style.zIndex = String(1000 - i)
222
+ el.style.pointerEvents = visible ? "auto" : "none"
223
+ el.tabIndex = visible ? 0 : -1
224
+
225
+ acc += heights[i] || 0
226
+ })
227
+
228
+ this._enforceMax(items)
229
+ }
230
+
231
+ _enforceMax(items) {
232
+ if (items.length <= this.maxValue) return
233
+ const isBottom = this.positionValue.startsWith("bottom")
234
+ const dropping = items.length - this.maxValue
235
+ const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)
236
+ candidates.forEach(el => {
237
+ if (el.dataset.state !== "closing") {
238
+ el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
239
+ }
240
+ })
241
+ }
242
+
243
+ _onKey(e) {
244
+ const parts = (this.hotkeyValue || "alt+t").split("+")
245
+ const key = parts.pop()
246
+ const wantAlt = parts.includes("alt")
247
+ const wantCtrl = parts.includes("ctrl")
248
+ const wantMeta = parts.includes("meta")
249
+ if (e.key.toLowerCase() !== key.toLowerCase()) return
250
+ if (wantAlt !== e.altKey) return
251
+ if (wantCtrl !== e.ctrlKey) return
252
+ if (wantMeta !== e.metaKey) return
253
+ e.preventDefault()
254
+ const first = this._listEl.firstElementChild
255
+ first?.focus()
256
+ }
257
+
258
+ _registerGlobalApi() {
259
+ const fire = (variant, message, opts = {}) =>
260
+ window.dispatchEvent(new CustomEvent("ruby-ui:toast", {
261
+ detail: { ...opts, variant, message: opts.title || message }
262
+ }))
263
+
264
+ const api = (message, opts) => fire("default", message, opts)
265
+ api.success = (m, o) => fire("success", m, o)
266
+ api.error = (m, o) => fire("error", m, o)
267
+ api.warning = (m, o) => fire("warning", m, o)
268
+ api.info = (m, o) => fire("info", m, o)
269
+ api.loading = (m, o = {}) => fire("loading", m, { ...o, duration: o.duration ?? 0 })
270
+ api.dismiss = (id) => {
271
+ if (id) this._dismissById(id)
272
+ else window.dispatchEvent(new CustomEvent("ruby-ui:toast:dismiss-all"))
273
+ }
274
+ api.promise = (p, msgs = {}) => {
275
+ const id = `toast-${this._uuid()}`
276
+ fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0 })
277
+ Promise.resolve(p).then(
278
+ (val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success),
279
+ (err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error)
280
+ )
281
+ return id
282
+ }
283
+
284
+ window.RubyUI = window.RubyUI || {}
285
+ window.RubyUI.toast = api
286
+ }
287
+
288
+ _mutate(id, variant, text) {
289
+ const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
290
+ if (!el) return
291
+ el.dataset.variant = variant
292
+ el.setAttribute("role", variant === "error" ? "alert" : "status")
293
+ this._swapIcon(el, variant)
294
+ const t = el.querySelector('[data-slot="title"]')
295
+ if (t && text) t.textContent = text
296
+ const dur = String(this.durationValue)
297
+ el.setAttribute("data-ruby-ui--toast-duration-value", dur)
298
+ el.dispatchEvent(new CustomEvent("ruby-ui:toast:restart", { bubbles: true }))
299
+ }
300
+
301
+ _swapIcon(el, variant) {
302
+ const iconHost = el.querySelector('[data-slot="icon"]')
303
+ if (!iconHost) return
304
+ const tpl = this._skeletonFor(variant)
305
+ if (!tpl) return
306
+ const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot="icon"]')
307
+ iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : ""
308
+ }
309
+
310
+ _uuid() {
311
+ if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID()
312
+ return Math.random().toString(36).slice(2) + Date.now().toString(36)
313
+ }
314
+ }