alchemy_cms 8.2.7 → 8.3.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 (189) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -1
  3. data/app/assets/builds/alchemy/admin.css +1 -1
  4. data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
  5. data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
  6. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  7. data/app/assets/builds/alchemy/light-theme.css +1 -1
  8. data/app/assets/builds/alchemy/preview.min.js +1 -1
  9. data/app/assets/builds/alchemy/theme.css +1 -1
  10. data/app/assets/builds/alchemy/welcome.css +1 -1
  11. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  12. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
  13. data/app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css +1 -1
  14. data/app/assets/builds/tinymce/skins/ui/alchemy-dark/skin.min.css +1 -1
  15. data/app/assets/images/alchemy/admin/logo.svg +27 -0
  16. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  17. data/app/components/alchemy/admin/dashboard/widget.rb +40 -0
  18. data/app/components/alchemy/admin/dashboard/widgets/attachment_counts.rb +17 -0
  19. data/app/components/alchemy/admin/dashboard/widgets/element_usage.rb +37 -0
  20. data/app/components/alchemy/admin/dashboard/widgets/greeting.html.erb +13 -0
  21. data/app/components/alchemy/admin/dashboard/widgets/greeting.rb +21 -0
  22. data/app/components/alchemy/admin/dashboard/widgets/locked_pages.html.erb +54 -0
  23. data/app/components/alchemy/admin/dashboard/widgets/locked_pages.rb +20 -0
  24. data/app/components/alchemy/admin/dashboard/widgets/online_users.html.erb +22 -0
  25. data/app/components/alchemy/admin/dashboard/widgets/online_users.rb +19 -0
  26. data/app/components/alchemy/admin/dashboard/widgets/page_counts.rb +23 -0
  27. data/app/components/alchemy/admin/dashboard/widgets/page_usage.rb +46 -0
  28. data/app/components/alchemy/admin/dashboard/widgets/picture_counts.rb +17 -0
  29. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.html.erb +41 -0
  30. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.rb +16 -0
  31. data/app/components/alchemy/admin/dashboard/widgets/sites.html.erb +29 -0
  32. data/app/components/alchemy/admin/dashboard/widgets/sites.rb +15 -0
  33. data/app/components/alchemy/admin/dashboard/widgets/stat_widget.html.erb +23 -0
  34. data/app/components/alchemy/admin/dashboard/widgets/stat_widget.rb +19 -0
  35. data/app/components/alchemy/admin/dashboard/widgets/system_info.html.erb +32 -0
  36. data/app/components/alchemy/admin/dashboard/widgets/system_info.rb +37 -0
  37. data/app/components/alchemy/admin/dashboard/widgets/usage_widget.html.erb +42 -0
  38. data/app/components/alchemy/admin/dashboard/widgets/usage_widget.rb +66 -0
  39. data/app/components/alchemy/admin/dashboard/widgets/user_counts.rb +25 -0
  40. data/app/components/alchemy/admin/element_editor.html.erb +27 -20
  41. data/app/components/alchemy/admin/element_schedule_timestamps.rb +33 -0
  42. data/app/components/alchemy/admin/element_select.rb +4 -3
  43. data/app/components/alchemy/admin/page_node.html.erb +1 -20
  44. data/app/components/alchemy/admin/page_publication_fields.html.erb +30 -0
  45. data/app/components/alchemy/admin/page_publication_fields.rb +18 -0
  46. data/app/components/alchemy/admin/page_status_indicators.html.erb +29 -0
  47. data/app/components/alchemy/admin/page_status_indicators.rb +9 -0
  48. data/app/components/alchemy/admin/publish_element_button.html.erb +12 -4
  49. data/app/components/alchemy/ingredients/headline_editor.rb +1 -1
  50. data/app/controllers/alchemy/admin/dashboard/widgets_controller.rb +21 -0
  51. data/app/controllers/alchemy/admin/dashboard_controller.rb +3 -12
  52. data/app/controllers/alchemy/pages_controller.rb +5 -4
  53. data/app/helpers/alchemy/elements_block_helper.rb +1 -0
  54. data/app/javascript/alchemy_admin/components/auto_submit.js +15 -9
  55. data/app/javascript/alchemy_admin/components/char_counter.js +17 -7
  56. data/app/javascript/alchemy_admin/components/clipboard_button.js +2 -6
  57. data/app/javascript/alchemy_admin/components/color_select.js +13 -4
  58. data/app/javascript/alchemy_admin/components/datepicker.js +11 -14
  59. data/app/javascript/alchemy_admin/components/dialog_link.js +5 -2
  60. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +6 -3
  61. data/app/javascript/alchemy_admin/components/element_editor.js +45 -28
  62. data/app/javascript/alchemy_admin/components/element_select.js +7 -4
  63. data/app/javascript/alchemy_admin/components/elements_window.js +38 -31
  64. data/app/javascript/alchemy_admin/components/elements_window_handle.js +7 -3
  65. data/app/javascript/alchemy_admin/components/file_editor.js +5 -2
  66. data/app/javascript/alchemy_admin/components/ingredient_group.js +6 -4
  67. data/app/javascript/alchemy_admin/components/link_buttons/link_button.js +1 -2
  68. data/app/javascript/alchemy_admin/components/link_buttons/unlink_button.js +1 -2
  69. data/app/javascript/alchemy_admin/components/link_buttons.js +6 -2
  70. data/app/javascript/alchemy_admin/components/list_filter.js +44 -29
  71. data/app/javascript/alchemy_admin/components/message.js +22 -15
  72. data/app/javascript/alchemy_admin/components/overlay.js +5 -7
  73. data/app/javascript/alchemy_admin/components/page_publication_fields.js +38 -25
  74. data/app/javascript/alchemy_admin/components/picture_description_select.js +5 -2
  75. data/app/javascript/alchemy_admin/components/picture_editor.js +5 -10
  76. data/app/javascript/alchemy_admin/components/picture_thumbnail.js +4 -5
  77. data/app/javascript/alchemy_admin/components/preview_window.js +5 -10
  78. data/app/javascript/alchemy_admin/components/publish_page_button.js +2 -5
  79. data/app/javascript/alchemy_admin/components/remote_select.js +53 -23
  80. data/app/javascript/alchemy_admin/components/select.js +169 -26
  81. data/app/javascript/alchemy_admin/components/sortable_elements.js +1 -1
  82. data/app/javascript/alchemy_admin/components/spinner.js +11 -11
  83. data/app/javascript/alchemy_admin/components/tags_autocomplete.js +9 -1
  84. data/app/javascript/alchemy_admin/components/tinymce.js +16 -22
  85. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +48 -45
  86. data/app/javascript/alchemy_admin/components/uploader/progress.js +70 -84
  87. data/app/javascript/alchemy_admin/components/uploader.js +71 -46
  88. data/app/javascript/alchemy_admin/dialog.js +3 -0
  89. data/app/javascript/alchemy_admin/hotkeys.js +0 -18
  90. data/app/javascript/alchemy_admin/image_cropper.js +7 -9
  91. data/app/javascript/alchemy_admin/initializer.js +21 -0
  92. data/app/javascript/alchemy_admin/utils/dispatch_page_dirty_event.js +7 -0
  93. data/app/javascript/tinymce/plugins/alchemy_link/index.js +9 -0
  94. data/app/jobs/alchemy/base_job.rb +2 -2
  95. data/app/jobs/alchemy/invalidate_elements_cache_job.rb +33 -0
  96. data/app/models/alchemy/page/page_naming.rb +28 -5
  97. data/app/models/alchemy/page/page_natures.rb +7 -2
  98. data/app/models/alchemy/page/page_scopes.rb +2 -2
  99. data/app/models/alchemy/page/url_path.rb +7 -2
  100. data/app/models/alchemy/page.rb +2 -2
  101. data/app/models/alchemy/page_definition.rb +1 -0
  102. data/app/models/alchemy/permissions.rb +1 -1
  103. data/app/models/concerns/alchemy/relatable_resource.rb +8 -0
  104. data/app/services/alchemy/page_finder.rb +88 -0
  105. data/app/stylesheets/alchemy/_custom-properties.scss +6 -4
  106. data/app/stylesheets/alchemy/_mixins.scss +1 -7
  107. data/app/stylesheets/alchemy/_themes.scss +13 -1
  108. data/app/stylesheets/alchemy/admin/_tom-select.scss +240 -0
  109. data/app/stylesheets/alchemy/admin/archive.scss +0 -1
  110. data/app/stylesheets/alchemy/admin/base.scss +0 -19
  111. data/app/stylesheets/alchemy/admin/dashboard.scss +395 -28
  112. data/app/stylesheets/alchemy/admin/elements.scss +14 -17
  113. data/app/stylesheets/alchemy/admin/form_fields.scss +3 -3
  114. data/app/stylesheets/alchemy/admin/forms.scss +107 -93
  115. data/app/stylesheets/alchemy/admin/icons.scss +28 -0
  116. data/app/stylesheets/alchemy/admin/image_library.scss +20 -10
  117. data/app/stylesheets/alchemy/admin/navigation.scss +4 -1
  118. data/app/stylesheets/alchemy/admin/popover.scss +3 -5
  119. data/app/stylesheets/alchemy/admin/resource_info.scss +11 -17
  120. data/app/stylesheets/alchemy/admin/shoelace.scss +8 -0
  121. data/app/stylesheets/alchemy/admin/sitemap.scss +5 -0
  122. data/app/stylesheets/alchemy/admin/tables.scss +32 -3
  123. data/app/stylesheets/alchemy/admin/toolbar.scss +0 -1
  124. data/app/stylesheets/alchemy/admin.scss +1 -0
  125. data/app/stylesheets/tinymce/skins/ui/alchemy/skin.scss +0 -4
  126. data/app/stylesheets/tinymce/skins/ui/alchemy-dark/skin.scss +0 -4
  127. data/app/types/alchemy/wildcard_url_type.rb +48 -0
  128. data/app/views/alchemy/_menubar.html.erb +1 -5
  129. data/app/views/alchemy/admin/attachments/edit.html.erb +6 -3
  130. data/app/views/alchemy/admin/dashboard/_dashboard.html.erb +3 -2
  131. data/app/views/alchemy/admin/dashboard/_footer.html.erb +22 -0
  132. data/app/views/alchemy/admin/dashboard/_stats.html.erb +7 -0
  133. data/app/views/alchemy/admin/dashboard/_top.html.erb +4 -12
  134. data/app/views/alchemy/admin/dashboard/_widgets.html.erb +7 -0
  135. data/app/views/alchemy/admin/dashboard/index.html.erb +0 -17
  136. data/app/views/alchemy/admin/dashboard/info.html.erb +1 -62
  137. data/app/views/alchemy/admin/dashboard/widgets/show.html.erb +3 -0
  138. data/app/views/alchemy/admin/elements/_form.html.erb +2 -1
  139. data/app/views/alchemy/admin/elements/_schedule.html.erb +2 -15
  140. data/app/views/alchemy/admin/elements/_schedule_fields.html.erb +2 -0
  141. data/app/views/alchemy/admin/layoutpages/edit.html.erb +6 -3
  142. data/app/views/alchemy/admin/nodes/_page_nodes.html.erb +10 -8
  143. data/app/views/alchemy/admin/pages/_form.html.erb +25 -19
  144. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +2 -32
  145. data/app/views/alchemy/admin/pages/_table.html.erb +1 -18
  146. data/app/views/alchemy/admin/pages/configure.html.erb +2 -2
  147. data/app/views/alchemy/admin/pages/info.html.erb +6 -0
  148. data/app/views/alchemy/admin/resources/_form.html.erb +7 -4
  149. data/app/views/alchemy/admin/resources/edit.html.erb +3 -1
  150. data/app/views/alchemy/admin/resources/new.html.erb +3 -1
  151. data/app/views/alchemy/admin/styleguide/index.html.erb +52 -30
  152. data/app/views/alchemy/admin/translations/_en.js +4 -0
  153. data/app/views/layouts/alchemy/admin.html.erb +3 -3
  154. data/config/importmap.rb +2 -0
  155. data/config/locales/alchemy.en.yml +15 -0
  156. data/config/routes.rb +1 -0
  157. data/lib/alchemy/configuration/class_option.rb +46 -3
  158. data/lib/alchemy/configuration/collection_option.rb +4 -0
  159. data/lib/alchemy/configurations/dashboard.rb +79 -0
  160. data/lib/alchemy/configurations/main.rb +15 -0
  161. data/lib/alchemy/engine.rb +9 -3
  162. data/lib/alchemy/sprockets/skip_builds_compression.rb +33 -0
  163. data/lib/alchemy/test_support/capybara_helpers.rb +17 -0
  164. data/lib/alchemy/test_support/relatable_resource_examples.rb +20 -0
  165. data/lib/alchemy/test_support/rspec_matchers.rb +8 -0
  166. data/lib/alchemy/test_support/shared_publishable_examples.rb +38 -31
  167. data/lib/alchemy/tinymce.rb +1 -1
  168. data/lib/alchemy/version.rb +17 -3
  169. data/vendor/javascript/cropperjs.min.js +1 -1
  170. data/vendor/javascript/flatpickr.min.js +1 -1
  171. data/vendor/javascript/floating-ui.min.js +1 -0
  172. data/vendor/javascript/keymaster.min.js +1 -1
  173. data/vendor/javascript/rails-ujs.min.js +1 -1
  174. data/vendor/javascript/shoelace.min.js +93 -93
  175. data/vendor/javascript/sortable.min.js +1 -1
  176. data/vendor/javascript/tinymce.min.js +5 -1
  177. data/vendor/javascript/tom-select.min.js +1 -0
  178. metadata +57 -18
  179. data/app/javascript/alchemy_admin/components/alchemy_html_element.js +0 -129
  180. data/app/views/alchemy/admin/dashboard/_left_column.html.erb +0 -4
  181. data/app/views/alchemy/admin/dashboard/_right_column.html.erb +0 -9
  182. data/app/views/alchemy/admin/dashboard/widgets/_locked_pages.html.erb +0 -52
  183. data/app/views/alchemy/admin/dashboard/widgets/_recent_pages.html.erb +0 -34
  184. data/app/views/alchemy/admin/dashboard/widgets/_sites.html.erb +0 -25
  185. data/app/views/alchemy/admin/dashboard/widgets/_users.html.erb +0 -21
  186. data/app/views/alchemy/admin/languages/edit.html.erb +0 -1
  187. data/app/views/alchemy/admin/languages/new.html.erb +0 -1
  188. data/app/views/alchemy/admin/sites/edit.html.erb +0 -1
  189. data/app/views/alchemy/admin/sites/new.html.erb +0 -1
@@ -5,13 +5,6 @@ class PreviewWindow extends HTMLIFrameElement {
5
5
  #afterLoad
6
6
  #reloadIcon
7
7
  #loadTimeout
8
- #previewReadyHandler
9
-
10
- constructor() {
11
- super()
12
- this.addEventListener("load", this)
13
- this.#previewReadyHandler = this.#handlePreviewReadyMessage.bind(this)
14
- }
15
8
 
16
9
  handleEvent(evt) {
17
10
  if (evt.type === "load") {
@@ -21,7 +14,7 @@ class PreviewWindow extends HTMLIFrameElement {
21
14
  }
22
15
  }
23
16
 
24
- #handlePreviewReadyMessage(event) {
17
+ #onPreviewReady = (event) => {
25
18
  if (event.data.message === "Alchemy.previewReady") {
26
19
  this.#clearLoadTimeout()
27
20
  this.#stopSpinner()
@@ -32,8 +25,9 @@ class PreviewWindow extends HTMLIFrameElement {
32
25
  connectedCallback() {
33
26
  let url = this.url
34
27
 
28
+ this.addEventListener("load", this)
35
29
  this.#attachEvents()
36
- window.addEventListener("message", this.#previewReadyHandler)
30
+ window.addEventListener("message", this.#onPreviewReady)
37
31
 
38
32
  if (window.localStorage.getItem("alchemy-preview-url")) {
39
33
  url = window.localStorage.getItem("alchemy-preview-url")
@@ -44,8 +38,9 @@ class PreviewWindow extends HTMLIFrameElement {
44
38
  }
45
39
 
46
40
  disconnectedCallback() {
41
+ this.removeEventListener("load", this)
47
42
  key.unbind("alt+r")
48
- window.removeEventListener("message", this.#previewReadyHandler)
43
+ window.removeEventListener("message", this.#onPreviewReady)
49
44
  }
50
45
 
51
46
  postMessage(data) {
@@ -1,14 +1,11 @@
1
1
  class PublishPageButton extends HTMLElement {
2
- constructor() {
3
- super()
4
- this.addEventListener("submit", this)
5
- }
6
-
7
2
  connectedCallback() {
3
+ this.addEventListener("submit", this)
8
4
  document.addEventListener("alchemy:page-dirty", this)
9
5
  }
10
6
 
11
7
  disconnectedCallback() {
8
+ this.removeEventListener("submit", this)
12
9
  document.removeEventListener("alchemy:page-dirty", this)
13
10
  }
14
11
 
@@ -1,40 +1,38 @@
1
- import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
2
1
  import { setupSelectLocale } from "alchemy_admin/i18n"
3
2
 
4
3
  export function hightlightTerm(name, term) {
5
4
  return name.replace(new RegExp(term, "gi"), (match) => `<em>${match}</em>`)
6
5
  }
7
6
 
8
- export class RemoteSelect extends AlchemyHTMLElement {
9
- static properties = {
10
- allowClear: { default: false },
11
- selection: { default: undefined },
12
- placeholder: { default: "" },
13
- queryParams: { default: "{}" },
14
- url: { default: "" }
15
- }
16
-
17
- // Select2 manages its own DOM after initialization, so attribute changes
18
- // must not trigger the default re-render which would destroy the widget.
19
- static get observedAttributes() {
20
- return []
21
- }
7
+ export class RemoteSelect extends HTMLElement {
8
+ #select2 = null
22
9
 
23
- async connected() {
10
+ async connectedCallback() {
24
11
  await setupSelectLocale()
12
+ // Bail out if the element was disconnected while the locale was loading.
13
+ // Otherwise Select2 would leak onto a detached input.
14
+ if (!this.isConnected) return
25
15
 
26
16
  this.input.classList.add("alchemy_selectbox")
27
17
 
28
- $(this.input)
18
+ this.#select2 = $(this.input)
29
19
  .select2(this.select2Config)
30
- .on("select2-open", (evt) => {
31
- this.onOpen(evt)
32
- })
33
- .on("change", (evt) => {
34
- this.onChange(evt)
35
- })
20
+ .on("select2-open", this.#onOpen)
21
+ .on("change", this.#onChange)
22
+ }
23
+
24
+ disconnectedCallback() {
25
+ if (this.#select2) {
26
+ this.#select2.off("select2-open", this.#onOpen)
27
+ this.#select2.off("change", this.#onChange)
28
+ this.#select2.select2("destroy")
29
+ this.#select2 = null
30
+ }
36
31
  }
37
32
 
33
+ #onOpen = (evt) => this.onOpen(evt)
34
+ #onChange = (evt) => this.onChange(evt)
35
+
38
36
  /**
39
37
  * Optional on change handler called by Select2.
40
38
  * @param {Event} event
@@ -65,6 +63,38 @@ export class RemoteSelect extends AlchemyHTMLElement {
65
63
  }, 100)
66
64
  }
67
65
 
66
+ /**
67
+ * Dispatches a custom event with given name, namespaced under `Alchemy.`.
68
+ * Subclasses may call this to emit their own events.
69
+ * @param {string} name The name of the custom event
70
+ * @param {object} detail Optional event details
71
+ */
72
+ dispatchCustomEvent(name, detail = {}) {
73
+ this.dispatchEvent(
74
+ new CustomEvent(`Alchemy.${name}`, { bubbles: true, detail })
75
+ )
76
+ }
77
+
78
+ get allowClear() {
79
+ return this.hasAttribute("allow-clear")
80
+ }
81
+
82
+ get selection() {
83
+ return this.getAttribute("selection")
84
+ }
85
+
86
+ get placeholder() {
87
+ return this.getAttribute("placeholder") ?? ""
88
+ }
89
+
90
+ get queryParams() {
91
+ return this.getAttribute("query-params") ?? "{}"
92
+ }
93
+
94
+ get url() {
95
+ return this.getAttribute("url") ?? ""
96
+ }
97
+
68
98
  get input() {
69
99
  return this.getElementsByTagName("input")[0]
70
100
  }
@@ -1,37 +1,44 @@
1
+ import TomSelect from "tom-select"
2
+ import { translate } from "alchemy_admin/i18n"
3
+ import {
4
+ autoUpdate,
5
+ computePosition,
6
+ flip,
7
+ offset,
8
+ size
9
+ } from "@floating-ui/dom"
10
+
11
+ const DROPDOWN_WINDOW_MARGIN = 16
12
+ const DROPDOWN_MIN_HEIGHT = 120
13
+
1
14
  class Select extends HTMLSelectElement {
2
- #select2Element
15
+ #tomSelect = null
3
16
 
4
17
  connectedCallback() {
5
18
  this.classList.add("alchemy_selectbox")
19
+ this.#initTomSelect()
20
+ }
6
21
 
7
- this.#select2Element = $(this).select2({
8
- minimumResultsForSearch: 5,
9
- dropdownAutoWidth: true,
10
- allowClear: !!this.allowClear
11
- })
12
-
13
- // For single selects, remove the close button if allowClear is not set
14
- // For multiple selects, always keep the close buttons
15
- if (!this.allowClear && !this.multiple) {
16
- this.#select2Element
17
- .prev(".select2-container")
18
- .find(".select2-search-choice-close")
19
- .remove()
20
- }
22
+ disconnectedCallback() {
23
+ this.#destroyTomSelect()
21
24
  }
22
25
 
23
26
  enable() {
24
27
  this.removeAttribute("disabled")
25
- this.#updateSelect2()
28
+ this.#tomSelect?.enable()
26
29
  }
27
30
 
28
31
  disable() {
29
32
  this.setAttribute("disabled", "disabled")
30
- this.#updateSelect2()
33
+ this.#tomSelect?.disable()
31
34
  }
32
35
 
33
36
  setOptions(data, prompt = undefined) {
34
- let selectedValue = this.value
37
+ const selectedValue = this.value
38
+
39
+ // Tom Select needs to be rebuilt from the new native options, so tear it
40
+ // down, replace the options and initialize it again.
41
+ this.#destroyTomSelect()
35
42
 
36
43
  // reset the old options and insert the placeholder(s) first
37
44
  this.innerHTML = ""
@@ -44,19 +51,155 @@ class Select extends HTMLSelectElement {
44
51
  this.add(new Option(item.text, item.id, false, item.id === selectedValue))
45
52
  })
46
53
 
47
- this.#updateSelect2()
48
- }
49
-
50
- /**
51
- * inform Select2 to update
52
- */
53
- #updateSelect2() {
54
- this.#select2Element.trigger("change")
54
+ this.#initTomSelect()
55
55
  }
56
56
 
57
57
  get allowClear() {
58
58
  return this.dataset.hasOwnProperty("allowClear") || this.multiple
59
59
  }
60
+
61
+ get placeholder() {
62
+ return this.getAttribute("placeholder")
63
+ }
64
+
65
+ #initTomSelect() {
66
+ const plugins = {}
67
+ const hasPlaceholder = !!this.placeholder
68
+ // Capture this before Tom Select initializes, since it rewrites the
69
+ // select's selected option during setup.
70
+ const hasSelectedOption = !!this.querySelector("option[selected]")
71
+ const dropdownMask = document.createElement("div")
72
+ dropdownMask.className = "ts-dropdown-mask"
73
+
74
+ let removeAutoUpdater = () => {}
75
+
76
+ if (this.multiple) {
77
+ plugins.remove_button = {
78
+ title: translate("Remove")
79
+ }
80
+ }
81
+
82
+ if (this.allowClear) {
83
+ plugins.clear_button = {
84
+ html() {
85
+ return `<button type="button" class="clear-button" aria-label="${translate(
86
+ "Clear selection"
87
+ )}">
88
+ <alchemy-icon name="close" size="1x"></alchemy-icon>
89
+ </button>`
90
+ }
91
+ }
92
+ }
93
+
94
+ const settings = {
95
+ plugins,
96
+ closeAfterSelect: !this.multiple,
97
+ onInitialize: function () {
98
+ if (this.input.autofocus) {
99
+ this.focus()
100
+ }
101
+ // Tom Select auto-selects the first option when none is selected. With
102
+ // a placeholder we want it to start empty instead, but only clear when
103
+ // no option was explicitly marked selected, so a preselected value is
104
+ // preserved.
105
+ if (hasPlaceholder && !hasSelectedOption) {
106
+ this.clear()
107
+ }
108
+ },
109
+ onType(term) {
110
+ this.control_input.classList.toggle("has-value", term.length > 0)
111
+ },
112
+ // remove the transition after selection of option.
113
+ refreshThrottle: 0,
114
+ onDropdownOpen: async function () {
115
+ // Make the dropdown at least as wide as the control.
116
+ const styles = {
117
+ minWidth: `${this.control.offsetWidth}px`
118
+ }
119
+ // If the select is inside a dialog, we need to ensure the dropdown appears above it.
120
+ if (this.control.closest(".alchemy-dialog-body, .alchemy-popover")) {
121
+ styles.zIndex = "101"
122
+ }
123
+ Object.assign(this.dropdown.style, styles)
124
+ // Append the dropdown to the body to avoid overflow issues, especially in dialogs.
125
+ document.body.append(dropdownMask)
126
+ document.body.append(this.dropdown)
127
+ // Use Floating UI to position the dropdown relative to the control.
128
+ const updatePosition = async () => {
129
+ // Use Floating UI to calculate the dropdown position
130
+ const { x, y } = await computePosition(this.control, this.dropdown, {
131
+ middleware: [
132
+ // Flip to the opposite side if there’s not enough space
133
+ flip(),
134
+ // Make some space between the control and the dropdown to prevent overlap
135
+ offset(2),
136
+ // Ensure the dropdown fits within the viewport
137
+ size({
138
+ apply({ availableHeight, elements }) {
139
+ Object.assign(
140
+ elements.floating.querySelector(".ts-dropdown-content")
141
+ .style,
142
+ {
143
+ maxHeight: `${Math.max(DROPDOWN_MIN_HEIGHT, availableHeight - DROPDOWN_WINDOW_MARGIN)}px`
144
+ }
145
+ )
146
+ }
147
+ })
148
+ ]
149
+ })
150
+ // Position the dropdown
151
+ Object.assign(this.dropdown.style, {
152
+ left: `${x}px`,
153
+ top: `${y}px`
154
+ })
155
+ }
156
+ // Update the dropdown position whenever the window resizes or scrolls.
157
+ removeAutoUpdater = autoUpdate(
158
+ this.control,
159
+ this.dropdown,
160
+ updatePosition
161
+ )
162
+ },
163
+ onDropdownClose: function () {
164
+ this.control_input.classList.remove("has-value")
165
+ // Remove the dropdown from DOM when closed.
166
+ this.dropdown.remove()
167
+ dropdownMask.remove()
168
+ // Cleanup the position auto-update when the dropdown is closed.
169
+ removeAutoUpdater()
170
+ },
171
+ allowEmptyOption: true,
172
+ openOnFocus: false,
173
+ // Keep options in their original order instead of sorting by value.
174
+ sortField: "$order",
175
+ // Show every option, not just the first 50 (e.g. the timezone select).
176
+ maxOptions: null,
177
+ // Customize the "create" and "no results" dropdown messages with i18n.
178
+ render: {
179
+ option_create(data, escape) {
180
+ return `<div class="create">
181
+ ${translate("Add")}<strong>${escape(data.input)}</strong>&hellip;
182
+ </div>`
183
+ },
184
+ no_results() {
185
+ return `<div class="no-results">${translate("No results found")}</div>`
186
+ }
187
+ }
188
+ }
189
+
190
+ this.#tomSelect = new TomSelect(this, settings)
191
+
192
+ // Mimick the native select's click-to-open behavior.
193
+ this.#tomSelect.control.addEventListener(
194
+ "click",
195
+ this.#tomSelect.open.bind(this.#tomSelect)
196
+ )
197
+ }
198
+
199
+ #destroyTomSelect() {
200
+ this.#tomSelect?.destroy()
201
+ this.#tomSelect = null
202
+ }
60
203
  }
61
204
 
62
205
  customElements.define("alchemy-select", Select, { extends: "select" })
@@ -2,7 +2,7 @@ import Sortable from "sortablejs"
2
2
  import { growl } from "alchemy_admin/growler"
3
3
  import { post } from "alchemy_admin/utils/ajax"
4
4
  import { reloadPreview } from "alchemy_admin/components/preview_window"
5
- import { dispatchPageDirtyEvent } from "alchemy_admin/components/element_editor"
5
+ import { dispatchPageDirtyEvent } from "alchemy_admin/utils/dispatch_page_dirty_event"
6
6
 
7
7
  const SORTABLE_OPTIONS = {
8
8
  draggable: ".element-editor",
@@ -1,15 +1,7 @@
1
- import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
2
-
3
- class Spinner extends AlchemyHTMLElement {
4
- static properties = {
5
- size: { default: "medium" },
6
- color: { default: "currentColor" }
7
- }
8
-
9
- render() {
1
+ class Spinner extends HTMLElement {
2
+ connectedCallback() {
10
3
  this.className = `spinner spinner--${this.size}`
11
-
12
- return `
4
+ this.innerHTML = `
13
5
  <svg width="100%" viewBox="0 0 28 28" style="--spinner-color: ${this.color}">
14
6
  <path
15
7
  class="hex1"
@@ -26,6 +18,14 @@ class Spinner extends AlchemyHTMLElement {
26
18
  </svg>
27
19
  `
28
20
  }
21
+
22
+ get size() {
23
+ return this.getAttribute("size") || "medium"
24
+ }
25
+
26
+ get color() {
27
+ return this.getAttribute("color") || "currentColor"
28
+ }
29
29
  }
30
30
 
31
31
  customElements.define("alchemy-spinner", Spinner)
@@ -1,11 +1,19 @@
1
1
  import { setupSelectLocale } from "alchemy_admin/i18n"
2
2
 
3
3
  class TagsAutocomplete extends HTMLElement {
4
+ #select2 = null
5
+
4
6
  async connectedCallback() {
5
7
  await setupSelectLocale()
8
+ if (!this.isConnected) return
6
9
 
7
10
  this.classList.add("autocomplete_tag_list")
8
- $(this.input).select2(this.select2Config)
11
+ this.#select2 = $(this.input).select2(this.select2Config)
12
+ }
13
+
14
+ disconnectedCallback() {
15
+ this.#select2?.select2("destroy")
16
+ this.#select2 = null
9
17
  }
10
18
 
11
19
  get input() {
@@ -1,19 +1,30 @@
1
1
  import "tinymce"
2
- import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
3
2
  import { currentLocale } from "alchemy_admin/i18n"
4
3
 
5
4
  const DARK_THEME = "alchemy-dark"
6
5
  const LIGHT_THEME = "alchemy"
7
6
 
8
- class Tinymce extends AlchemyHTMLElement {
7
+ class Tinymce extends HTMLElement {
9
8
  #min_height = null
10
9
 
11
10
  /**
12
11
  * the observer will initialize Tinymce if the textarea becomes visible
13
12
  */
14
- connected() {
13
+ connectedCallback() {
15
14
  this.className = "tinymce_container"
16
15
 
16
+ // Append the spinner if not already present (idempotent on reconnect/clone)
17
+ if (!this.querySelector(":scope > alchemy-spinner")) {
18
+ this.insertAdjacentHTML(
19
+ "beforeend",
20
+ `<alchemy-spinner size="small"></alchemy-spinner>`
21
+ )
22
+ }
23
+
24
+ // hide the textarea until TinyMCE is ready to show the editor
25
+ this.style.minHeight = `${this.minHeight}px`
26
+ this.editor.style.display = "none"
27
+
17
28
  const observerCallback = (entries, observer) => {
18
29
  entries.forEach((entry) => {
19
30
  if (entry.intersectionRatio > 0) {
@@ -43,10 +54,8 @@ class Tinymce extends AlchemyHTMLElement {
43
54
  /**
44
55
  * disconnect intersection observer and remove Tinymce editor if the web components get destroyed
45
56
  */
46
- disconnected() {
47
- if (this.tinymceIntersectionObserver !== null) {
48
- this.tinymceIntersectionObserver.disconnect()
49
- }
57
+ disconnectedCallback() {
58
+ this.tinymceIntersectionObserver?.disconnect()
50
59
 
51
60
  // Remove theme change listener
52
61
  this._removeThemeChangeListener()
@@ -54,21 +63,6 @@ class Tinymce extends AlchemyHTMLElement {
54
63
  tinymce.get(this.editorId)?.remove(this.editorId)
55
64
  }
56
65
 
57
- render() {
58
- return `
59
- ${this.initialContent}
60
- <alchemy-spinner size="small"></alchemy-spinner>
61
- `
62
- }
63
-
64
- /**
65
- * hide the textarea until TinyMCE is ready to show the editor
66
- */
67
- afterRender() {
68
- this.style.minHeight = `${this.minHeight}px`
69
- this.editor.style.display = "none"
70
- }
71
-
72
66
  /**
73
67
  * initialize Richtext area after the Intersection observer triggered
74
68
  * @private