Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/assets/builds/alchemy/admin.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/builds/alchemy/alchemy_admin.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/builds/alchemy/alchemy_admin.min.js.map

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions app/components/alchemy/admin/element_select.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,20 @@ module Admin
class ElementSelect < ViewComponent::Base
delegate :alchemy, to: :helpers

attr_reader :elements, :field_name
attr_reader :elements, :field_name, :autofocus

def initialize(elements, field_name: "element[name]")
def initialize(elements, field_name: "element[name]", autofocus: false)
@field_name = field_name
@elements = elements
@autofocus = autofocus
end

def call
content_tag "alchemy-element-select",
options: elements_options.to_json,
placeholder: Alchemy.t(:select_element) do
text_field_tag(field_name, nil, {
autofocus: true,
autofocus:,
required: true,
value: elements.many? ? nil : elements.first&.name,
class: "alchemy_selectbox full_width"
Expand Down
196 changes: 167 additions & 29 deletions app/javascript/alchemy_admin/components/select.js
Original file line number Diff line number Diff line change
@@ -1,42 +1,44 @@
import TomSelect from "tom-select"
import { translate } from "alchemy_admin/i18n"
import {
autoUpdate,
computePosition,
flip,
offset,
size
} from "@floating-ui/dom"

const DROPDOWN_WINDOW_MARGIN = 16
const DROPDOWN_MIN_HEIGHT = 120

class Select extends HTMLSelectElement {
#select2Element
#tomSelect = null

connectedCallback() {
this.classList.add("alchemy_selectbox")

this.#select2Element = $(this).select2({
minimumResultsForSearch: 5,
dropdownAutoWidth: true,
allowClear: !!this.allowClear
})

// For single selects, remove the close button if allowClear is not set
// For multiple selects, always keep the close buttons
if (!this.allowClear && !this.multiple) {
this.#select2Element
.prev(".select2-container")
.find(".select2-search-choice-close")
.remove()
}
this.#initTomSelect()
}

disconnectedCallback() {
this.#select2Element?.select2("destroy")
this.#select2Element = null
this.#destroyTomSelect()
}

enable() {
this.removeAttribute("disabled")
this.#updateSelect2()
this.#tomSelect?.enable()
}

disable() {
this.setAttribute("disabled", "disabled")
this.#updateSelect2()
this.#tomSelect?.disable()
}

setOptions(data, prompt = undefined) {
let selectedValue = this.value
const selectedValue = this.value

// Tom Select needs to be rebuilt from the new native options, so tear it
// down, replace the options and initialize it again.
this.#destroyTomSelect()

// reset the old options and insert the placeholder(s) first
this.innerHTML = ""
Expand All @@ -49,19 +51,155 @@ class Select extends HTMLSelectElement {
this.add(new Option(item.text, item.id, false, item.id === selectedValue))
})

this.#updateSelect2()
}

/**
* inform Select2 to update
*/
#updateSelect2() {
this.#select2Element.trigger("change")
this.#initTomSelect()
}

get allowClear() {
return this.dataset.hasOwnProperty("allowClear") || this.multiple
}

get placeholder() {
return this.getAttribute("placeholder")
}

#initTomSelect() {
const plugins = {}
const hasPlaceholder = !!this.placeholder
// Capture this before Tom Select initializes, since it rewrites the
// select's selected option during setup.
const hasSelectedOption = !!this.querySelector("option[selected]")
const dropdownMask = document.createElement("div")
dropdownMask.className = "ts-dropdown-mask"

let removeAutoUpdater = () => {}

if (this.multiple) {
plugins.remove_button = {
title: translate("Remove")
}
}

if (this.allowClear) {
plugins.clear_button = {
html() {
return `<button type="button" class="clear-button" aria-label="${translate(
"Clear selection"
)}">
<alchemy-icon name="close" size="1x"></alchemy-icon>
</button>`
}
}
}

const settings = {
plugins,
closeAfterSelect: !this.multiple,
onInitialize: function () {
if (this.input.autofocus) {
this.focus()
}
// Tom Select auto-selects the first option when none is selected. With
// a placeholder we want it to start empty instead, but only clear when
// no option was explicitly marked selected, so a preselected value is
// preserved.
if (hasPlaceholder && !hasSelectedOption) {
this.clear()
}
},
onType(term) {
this.control_input.classList.toggle("has-value", term.length > 0)
},
// remove the transition after selection of option.
refreshThrottle: 0,
onDropdownOpen: async function () {
// Make the dropdown at least as wide as the control.
const styles = {
minWidth: `${this.control.offsetWidth}px`
}
// If the select is inside a dialog, we need to ensure the dropdown appears above it.
if (this.control.closest(".alchemy-dialog-body, .alchemy-popover")) {
styles.zIndex = "101"
}
Object.assign(this.dropdown.style, styles)
// Append the dropdown to the body to avoid overflow issues, especially in dialogs.
document.body.append(dropdownMask)
document.body.append(this.dropdown)
// Use Floating UI to position the dropdown relative to the control.
const updatePosition = async () => {
// Use Floating UI to calculate the dropdown position
const { x, y } = await computePosition(this.control, this.dropdown, {
middleware: [
// Flip to the opposite side if there’s not enough space
flip(),
// Make some space between the control and the dropdown to prevent overlap
offset(2),
// Ensure the dropdown fits within the viewport
size({
apply({ availableHeight, elements }) {
Object.assign(
elements.floating.querySelector(".ts-dropdown-content")
.style,
{
maxHeight: `${Math.max(DROPDOWN_MIN_HEIGHT, availableHeight - DROPDOWN_WINDOW_MARGIN)}px`
}
)
}
})
]
})
// Position the dropdown
Object.assign(this.dropdown.style, {
left: `${x}px`,
top: `${y}px`
})
}
// Update the dropdown position whenever the window resizes or scrolls.
removeAutoUpdater = autoUpdate(
this.control,
this.dropdown,
updatePosition
)
},
onDropdownClose: function () {
this.control_input.classList.remove("has-value")
// Remove the dropdown from DOM when closed.
this.dropdown.remove()
dropdownMask.remove()
// Cleanup the position auto-update when the dropdown is closed.
removeAutoUpdater()
},
allowEmptyOption: true,
openOnFocus: false,
// Keep options in their original order instead of sorting by value.
sortField: "$order",
// Show every option, not just the first 50 (e.g. the timezone select).
maxOptions: null,
// Customize the "create" and "no results" dropdown messages with i18n.
render: {
option_create(data, escape) {
return `<div class="create">
${translate("Add")}<strong>${escape(data.input)}</strong>&hellip;
</div>`
},
no_results() {
return `<div class="no-results">${translate("No results found")}</div>`
}
}
}

this.#tomSelect = new TomSelect(this, settings)

// Mimick the native select's click-to-open behavior.
this.#tomSelect.control.addEventListener(
"click",
this.#tomSelect.open.bind(this.#tomSelect)
)
}

#destroyTomSelect() {
this.#tomSelect?.destroy()
this.#tomSelect = null
}
}

customElements.define("alchemy-select", Select, { extends: "select" })
1 change: 1 addition & 0 deletions app/stylesheets/alchemy/admin.scss
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
@use "admin/spinner";
@use "admin/tables";
@use "admin/tags";
@use "admin/tom-select";
@use "admin/toolbar";
@use "admin/typography";
@use "admin/upload";
Expand Down
Loading
Loading