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,197 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Connects to data-controller="ruby-ui--combobox"
4
+ // Vendored from RubyUI; the only change is `updatePopoverPosition`, which uses plain
5
+ // getBoundingClientRect positioning instead of @floating-ui/dom (so no bundler/extra dep).
6
+ export default class extends Controller {
7
+ static values = {
8
+ term: String,
9
+ minPopoverWidth: { type: Number, default: 240 }
10
+ }
11
+
12
+ static targets = [
13
+ "input",
14
+ "toggleAll",
15
+ "popover",
16
+ "item",
17
+ "emptyState",
18
+ "searchInput",
19
+ "trigger",
20
+ "triggerContent"
21
+ ]
22
+
23
+ selectedItemIndex = null
24
+
25
+ connect() {
26
+ this.updateTriggerContent()
27
+ }
28
+
29
+ disconnect() {
30
+ if (this.cleanup) { this.cleanup() }
31
+ }
32
+
33
+ handlePopoverToggle(event) {
34
+ this.triggerTarget.ariaExpanded = event.newState === 'open' ? 'true' : 'false'
35
+ }
36
+
37
+ inputChanged(e) {
38
+ this.updateTriggerContent()
39
+
40
+ if (e.target.type == "radio") {
41
+ this.closePopover()
42
+ }
43
+
44
+ if (this.hasToggleAllTarget && !e.target.checked) {
45
+ this.toggleAllTarget.checked = false
46
+ }
47
+ }
48
+
49
+ inputContent(input) {
50
+ return input.dataset.text || input.parentElement.textContent
51
+ }
52
+
53
+ toggleAllItems() {
54
+ const isChecked = this.toggleAllTarget.checked
55
+ this.inputTargets.forEach(input => input.checked = isChecked)
56
+ this.updateTriggerContent()
57
+ }
58
+
59
+ updateTriggerContent() {
60
+ const checkedInputs = this.inputTargets.filter(input => input.checked)
61
+
62
+ if (checkedInputs.length === 0) {
63
+ this.triggerContentTarget.innerText = this.triggerTarget.dataset.placeholder
64
+ } else if (this.termValue && checkedInputs.length > 1) {
65
+ this.triggerContentTarget.innerText = `${checkedInputs.length} ${this.termValue}`
66
+ } else {
67
+ this.triggerContentTarget.innerText = checkedInputs.map((input) => this.inputContent(input)).join(", ")
68
+ }
69
+ }
70
+
71
+ togglePopover(event) {
72
+ event.preventDefault()
73
+
74
+ if (this.triggerTarget.ariaExpanded === "true") {
75
+ this.closePopover()
76
+ } else {
77
+ this.openPopover(event)
78
+ }
79
+ }
80
+
81
+ openPopover(event) {
82
+ if (event) event.preventDefault()
83
+
84
+ this.updatePopoverPosition()
85
+ this.updatePopoverWidth()
86
+ this.triggerTarget.ariaExpanded = "true"
87
+ this.selectedItemIndex = null
88
+ this.itemTargets.forEach(item => item.ariaCurrent = "false")
89
+ this.popoverTarget.showPopover()
90
+ }
91
+
92
+ closePopover() {
93
+ this.triggerTarget.ariaExpanded = "false"
94
+ this.popoverTarget.hidePopover()
95
+ }
96
+
97
+ filterItems(e) {
98
+ if (["ArrowDown", "ArrowUp", "Tab", "Enter"].includes(e.key)) {
99
+ return
100
+ }
101
+
102
+ const filterTerm = this.searchInputTarget.value.toLowerCase()
103
+
104
+ if (this.hasToggleAllTarget) {
105
+ if (filterTerm) this.toggleAllTarget.parentElement.classList.add("hidden")
106
+ else this.toggleAllTarget.parentElement.classList.remove("hidden")
107
+ }
108
+
109
+ let resultCount = 0
110
+
111
+ this.selectedItemIndex = null
112
+
113
+ this.inputTargets.forEach((input) => {
114
+ const text = this.inputContent(input).toLowerCase()
115
+
116
+ if (text.indexOf(filterTerm) > -1) {
117
+ input.parentElement.classList.remove("hidden")
118
+ resultCount++
119
+ } else {
120
+ input.parentElement.classList.add("hidden")
121
+ }
122
+ })
123
+
124
+ this.emptyStateTarget.classList.toggle("hidden", resultCount !== 0)
125
+ }
126
+
127
+ keyDownPressed() {
128
+ if (this.selectedItemIndex !== null) {
129
+ this.selectedItemIndex++
130
+ } else {
131
+ this.selectedItemIndex = 0
132
+ }
133
+
134
+ this.focusSelectedInput()
135
+ }
136
+
137
+ keyUpPressed() {
138
+ if (this.selectedItemIndex !== null) {
139
+ this.selectedItemIndex--
140
+ } else {
141
+ this.selectedItemIndex = -1
142
+ }
143
+
144
+ this.focusSelectedInput()
145
+ }
146
+
147
+ focusSelectedInput() {
148
+ const visibleInputs = this.inputTargets.filter(input => !input.parentElement.classList.contains("hidden"))
149
+
150
+ this.wrapSelectedInputIndex(visibleInputs.length)
151
+
152
+ visibleInputs.forEach((input, index) => {
153
+ if (index == this.selectedItemIndex) {
154
+ input.parentElement.ariaCurrent = "true"
155
+ input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
156
+ } else {
157
+ input.parentElement.ariaCurrent = "false"
158
+ }
159
+ })
160
+ }
161
+
162
+ keyEnterPressed(event) {
163
+ event.preventDefault()
164
+ const option = this.itemTargets.find(item => item.ariaCurrent === "true")
165
+
166
+ if (option) {
167
+ option.click()
168
+ }
169
+ }
170
+
171
+ wrapSelectedInputIndex(length) {
172
+ this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length
173
+ }
174
+
175
+ // Position the popover under the trigger using viewport coordinates. The popover lives in
176
+ // the top layer (native Popover API), so a fixed position relative to the trigger's rect
177
+ // places it correctly without a positioning library.
178
+ updatePopoverPosition() {
179
+ const rect = this.triggerTarget.getBoundingClientRect()
180
+ const width = Math.max(this.triggerTarget.offsetWidth, this.minPopoverWidthValue)
181
+ // Keep the popover within the viewport (pull left when the trigger is near the right edge).
182
+ let left = rect.left
183
+ const maxLeft = window.innerWidth - width - 8
184
+ if (left > maxLeft) left = Math.max(8, maxLeft)
185
+ Object.assign(this.popoverTarget.style, {
186
+ position: "fixed",
187
+ top: `${rect.bottom + 4}px`,
188
+ left: `${left}px`,
189
+ margin: "0"
190
+ })
191
+ }
192
+
193
+ updatePopoverWidth() {
194
+ const width = Math.max(this.triggerTarget.offsetWidth, this.minPopoverWidthValue)
195
+ this.popoverTarget.style.width = `${width}px`
196
+ }
197
+ }
@@ -0,0 +1,7 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ close() {
5
+ this.element.remove()
6
+ }
7
+ }
@@ -0,0 +1,15 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["content"]
5
+
6
+ static values = { open: false }
7
+
8
+ connect() {
9
+ if (this.openValue) this.open()
10
+ }
11
+
12
+ open() {
13
+ document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML)
14
+ }
15
+ }
@@ -0,0 +1,67 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ const SIDEBAR_COOKIE_NAME = "sidebar_state";
4
+ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
5
+ const State = {
6
+ EXPANDED: "expanded",
7
+ COLLAPSED: "collapsed",
8
+ };
9
+ const MOBILE_BREAKPOINT = 768;
10
+
11
+ export default class extends Controller {
12
+ static targets = ["sidebar", "mobileSidebar"];
13
+
14
+ sidebarTargetConnected() {
15
+ const { state, collapsibleKind } = this.sidebarTarget.dataset;
16
+
17
+ this.open = state === State.EXPANDED;
18
+ this.collapsibleKind = collapsibleKind;
19
+ }
20
+
21
+ toggle(e) {
22
+ e.preventDefault();
23
+
24
+ if (this.#isMobile()) {
25
+ this.#openMobileSidebar();
26
+
27
+ return;
28
+ }
29
+
30
+ this.open = !this.open;
31
+ this.onToggle();
32
+ }
33
+
34
+ onToggle() {
35
+ this.#updateSidebarState();
36
+ this.#persistSidebarState();
37
+ }
38
+
39
+ #updateSidebarState() {
40
+ if (!this.hasSidebarTarget) {
41
+ return;
42
+ }
43
+
44
+ const { dataset } = this.sidebarTarget;
45
+
46
+ dataset.state = this.open ? State.EXPANDED : State.COLLAPSED;
47
+ dataset.collapsible = this.open ? "" : this.collapsibleKind;
48
+ }
49
+
50
+ #persistSidebarState() {
51
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${this.open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
52
+ }
53
+
54
+ #isMobile() {
55
+ return window.innerWidth < MOBILE_BREAKPOINT;
56
+ }
57
+
58
+ #openMobileSidebar() {
59
+ if (!this.hasMobileSidebarTarget) {
60
+ return;
61
+ }
62
+
63
+ this.mobileSidebarTarget.dispatchEvent(
64
+ new CustomEvent("ruby--ui-sidebar:open"),
65
+ );
66
+ }
67
+ }
@@ -0,0 +1,151 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const SWIPE_THRESHOLD = 45
4
+ const TIME_BEFORE_UNMOUNT = 200
5
+
6
+ // Connects to data-controller="ruby-ui--toast"
7
+ export default class extends Controller {
8
+ static values = {
9
+ duration: { type: Number, default: 4000 },
10
+ dismissible: { type: Boolean, default: true },
11
+ invert: { type: Boolean, default: false },
12
+ onDismiss: String,
13
+ onAutoClose: String,
14
+ }
15
+
16
+ connect() {
17
+ this._timer = null
18
+ this._startedAt = 0
19
+ this._remaining = this.durationValue
20
+ this._paused = false
21
+ this._swipe = { active: false, x: 0, y: 0, startedAt: 0 }
22
+
23
+ this._onPointerDown = this._onPointerDown.bind(this)
24
+ this._onPointerMove = this._onPointerMove.bind(this)
25
+ this._onPointerUp = this._onPointerUp.bind(this)
26
+ this._onPointerEnter = () => this._pause()
27
+ this._onPointerLeave = () => { if (!this._swipe.active) this._resume() }
28
+ this._onKeyDown = this._onKeyDown.bind(this)
29
+ this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }
30
+ this._onRestart = () => this._restart()
31
+ this._onRegionPause = () => this._pause()
32
+ this._onRegionResume = () => this._resume()
33
+
34
+ this.element.addEventListener("pointerdown", this._onPointerDown)
35
+ this.element.addEventListener("pointerenter", this._onPointerEnter)
36
+ this.element.addEventListener("pointerleave", this._onPointerLeave)
37
+ this.element.addEventListener("keydown", this._onKeyDown)
38
+ this.element.addEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss)
39
+ this.element.addEventListener("ruby-ui:toast:restart", this._onRestart)
40
+ document.addEventListener("ruby-ui:toast:pause", this._onRegionPause)
41
+ document.addEventListener("ruby-ui:toast:resume", this._onRegionResume)
42
+
43
+ requestAnimationFrame(() => {
44
+ this.element.dataset.state = "open"
45
+ this._start()
46
+ })
47
+ }
48
+
49
+ disconnect() {
50
+ this._clearTimer()
51
+ this.element.removeEventListener("pointerdown", this._onPointerDown)
52
+ this.element.removeEventListener("pointerenter", this._onPointerEnter)
53
+ this.element.removeEventListener("pointerleave", this._onPointerLeave)
54
+ this.element.removeEventListener("keydown", this._onKeyDown)
55
+ this.element.removeEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss)
56
+ this.element.removeEventListener("ruby-ui:toast:restart", this._onRestart)
57
+ document.removeEventListener("ruby-ui:toast:pause", this._onRegionPause)
58
+ document.removeEventListener("ruby-ui:toast:resume", this._onRegionResume)
59
+ }
60
+
61
+ dismiss(e) {
62
+ e?.preventDefault()
63
+ if (!this.dismissibleValue) return
64
+ this._close("dismiss")
65
+ }
66
+
67
+ _close(reason) {
68
+ if (this.element.dataset.state === "closing") return
69
+ this.element.dataset.state = "closing"
70
+ this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "ruby-ui:toast:auto-close" : "ruby-ui:toast:dismiss", { bubbles: true, detail: { id: this.element.id } }))
71
+ setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
72
+ }
73
+
74
+ _start() {
75
+ if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return
76
+ this._startedAt = performance.now()
77
+ this._remaining = this.durationValue
78
+ this._timer = setTimeout(() => this._close("auto"), this._remaining)
79
+ }
80
+
81
+ _restart() {
82
+ this._clearTimer()
83
+ this._start()
84
+ }
85
+
86
+ _pause() {
87
+ if (this._paused || !this._timer) return
88
+ this._paused = true
89
+ clearTimeout(this._timer)
90
+ this._timer = null
91
+ this._remaining -= performance.now() - this._startedAt
92
+ }
93
+
94
+ _resume() {
95
+ if (!this._paused) return
96
+ this._paused = false
97
+ if (this._remaining <= 0) return this._close("auto")
98
+ this._startedAt = performance.now()
99
+ this._timer = setTimeout(() => this._close("auto"), this._remaining)
100
+ }
101
+
102
+ _clearTimer() {
103
+ if (this._timer) clearTimeout(this._timer)
104
+ this._timer = null
105
+ }
106
+
107
+ _onKeyDown(e) {
108
+ if (e.key === "Escape" && this.dismissibleValue) this.dismiss(e)
109
+ }
110
+
111
+ _onPointerDown(e) {
112
+ if (!this.dismissibleValue) return
113
+ if (e.target.closest("button")) return
114
+ try { this.element.setPointerCapture(e.pointerId) } catch {}
115
+ this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId }
116
+ this.element.dataset.swipe = "start"
117
+ this.element.addEventListener("pointermove", this._onPointerMove)
118
+ this.element.addEventListener("pointerup", this._onPointerUp)
119
+ this.element.addEventListener("pointercancel", this._onPointerUp)
120
+ }
121
+
122
+ _onPointerMove(e) {
123
+ const dx = e.clientX - this._swipe.x
124
+ const dy = e.clientY - this._swipe.y
125
+ this.element.dataset.swipe = "move"
126
+ this.element.style.transform = `translate(${dx}px, ${dy}px)`
127
+ }
128
+
129
+ _onPointerUp(e) {
130
+ const dx = e.clientX - this._swipe.x
131
+ const dy = e.clientY - this._swipe.y
132
+ const dist = Math.hypot(dx, dy)
133
+ const dt = performance.now() - this._swipe.startedAt
134
+ const velocity = dist / Math.max(dt, 1)
135
+ this.element.removeEventListener("pointermove", this._onPointerMove)
136
+ this.element.removeEventListener("pointerup", this._onPointerUp)
137
+ this.element.removeEventListener("pointercancel", this._onPointerUp)
138
+ this._swipe.active = false
139
+ if (dist > SWIPE_THRESHOLD || velocity > 0.5) {
140
+ this.element.style.setProperty("--swipe-end-x", `${Math.sign(dx) * 500}px`)
141
+ this.element.style.setProperty("--swipe-end-y", `${Math.sign(dy) * 500}px`)
142
+ this.element.dataset.swipe = "end"
143
+ this.element.style.transform = ""
144
+ this._close("dismiss")
145
+ } else {
146
+ this.element.dataset.swipe = "cancel"
147
+ this.element.style.transform = ""
148
+ this._resume()
149
+ }
150
+ }
151
+ }