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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ The admin interface uses a **hybrid architecture**:

**Modern Web Components** (preferred for new features):
- Custom elements in `app/javascript/alchemy_admin/components/`
- Base class: `AlchemyHTMLElement` (extends `HTMLElement`)
Comment thread
tvdeyen marked this conversation as resolved.
- Extend `HTMLElement` directly; do all DOM work in `connectedCallback`
- Examples: `alchemy-sitemap`, `alchemy-element-editor`, `alchemy-datepicker`
- Vanilla JavaScript (no framework dependency)

Expand Down
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.

129 changes: 0 additions & 129 deletions app/javascript/alchemy_admin/components/alchemy_html_element.js

This file was deleted.

24 changes: 17 additions & 7 deletions app/javascript/alchemy_admin/components/char_counter.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
/**
* Show the character counter below input fields and textareas
*/
import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
import { translate } from "alchemy_admin/i18n"

class CharCounter extends AlchemyHTMLElement {
static properties = {
maxChars: { default: 60 }
}
connected() {
class CharCounter extends HTMLElement {
connectedCallback() {
this.translation = translate("allowed_chars", this.maxChars)
this.formField = this.getFormField()

if (this.formField) {
this.createDisplayElement()
this.countCharacters()
this.formField.addEventListener("keyup", () => this.countCharacters()) // add arrow function to get a implicit this - binding
this.formField.addEventListener("keyup", this)
}
}

disconnectedCallback() {
this.formField?.removeEventListener("keyup", this)
}

handleEvent(event) {
if (event.type === "keyup") this.countCharacters()
}

getFormField() {
const formFields = this.querySelectorAll("input, textarea")
return formFields.length > 0 ? formFields[0] : undefined
}

createDisplayElement() {
this.display = this.querySelector(":scope > .alchemy-char-counter")
if (this.display) return
this.display = document.createElement("small")
this.display.className = "alchemy-char-counter"
this.formField.after(this.display)
Expand All @@ -35,6 +41,10 @@ class CharCounter extends AlchemyHTMLElement {
this.display.textContent = `${charLength} ${this.translation}`
this.display.classList.toggle("too-long", charLength > this.maxChars)
}

get maxChars() {
return this.getAttribute("max-chars") ?? 60
}
}

customElements.define("alchemy-char-counter", CharCounter)
25 changes: 11 additions & 14 deletions app/javascript/alchemy_admin/components/datepicker.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
import { translate, currentLocale } from "alchemy_admin/i18n"
import flatpickr from "flatpickr"

const locale = currentLocale()

class Datepicker extends AlchemyHTMLElement {
static properties = {
inputType: { default: "date" }
}

constructor() {
super()
this.flatpickr = undefined
}

class Datepicker extends HTMLElement {
// Load the locales for flatpickr before setting it up.
async connected() {
async connectedCallback() {
// English is the default locale for flatpickr, so we don't need to load it
if (locale !== "en") {
await import(`flatpickr/${locale}.js`)
}
// Bail out if the element was disconnected while the locale was loading.
// Otherwise flatpickr would leak a calendar onto a detached input.
if (!this.isConnected) return

this.flatpickr = flatpickr(this.inputField, this.flatpickrOptions)
}

disconnected() {
this.flatpickr.destroy()
disconnectedCallback() {
this.flatpickr?.destroy()
}

get flatpickrOptions() {
Expand Down Expand Up @@ -56,6 +49,10 @@ class Datepicker extends AlchemyHTMLElement {
get inputField() {
return this.querySelector("input")
}

get inputType() {
return this.getAttribute("input-type") || "date"
}
}

customElements.define("alchemy-datepicker", Datepicker)
12 changes: 5 additions & 7 deletions app/javascript/alchemy_admin/components/overlay.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"

class Overlay extends AlchemyHTMLElement {
render() {
return `
class Overlay extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<alchemy-spinner></alchemy-spinner>
<div id="overlay_text_box">
<span id="overlay_text">${this.getAttribute("text")}</span>
<span id="overlay_text">${this.getAttribute("text") ?? ""}</span>
</div>
`
`
}

set show(value) {
Expand Down
70 changes: 53 additions & 17 deletions app/javascript/alchemy_admin/components/remote_select.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,38 @@
import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
import { setupSelectLocale } from "alchemy_admin/i18n"

export function hightlightTerm(name, term) {
return name.replace(new RegExp(term, "gi"), (match) => `<em>${match}</em>`)
}

export class RemoteSelect extends AlchemyHTMLElement {
static properties = {
allowClear: { default: false },
selection: { default: undefined },
placeholder: { default: "" },
queryParams: { default: "{}" },
url: { default: "" }
}
export class RemoteSelect extends HTMLElement {
#select2 = null

async connected() {
async connectedCallback() {
await setupSelectLocale()
// Bail out if the element was disconnected while the locale was loading.
// Otherwise Select2 would leak onto a detached input.
if (!this.isConnected) return

this.input.classList.add("alchemy_selectbox")

$(this.input)
this.#select2 = $(this.input)
.select2(this.select2Config)
.on("select2-open", (evt) => {
this.onOpen(evt)
})
.on("change", (evt) => {
this.onChange(evt)
})
.on("select2-open", this.#onOpen)
.on("change", this.#onChange)
}

disconnectedCallback() {
if (this.#select2) {
this.#select2.off("select2-open", this.#onOpen)
this.#select2.off("change", this.#onChange)
this.#select2.select2("destroy")
this.#select2 = null
}
}

#onOpen = (evt) => this.onOpen(evt)
#onChange = (evt) => this.onChange(evt)

/**
* Optional on change handler called by Select2.
* @param {Event} event
Expand All @@ -54,6 +58,38 @@ export class RemoteSelect extends AlchemyHTMLElement {
}, 100)
}

/**
* Dispatches a custom event with given name, namespaced under `Alchemy.`.
* Subclasses may call this to emit their own events.
* @param {string} name The name of the custom event
* @param {object} detail Optional event details
*/
dispatchCustomEvent(name, detail = {}) {
this.dispatchEvent(
new CustomEvent(`Alchemy.${name}`, { bubbles: true, detail })
)
}

get allowClear() {
return this.hasAttribute("allow-clear")
}

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

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

get queryParams() {
return this.getAttribute("query-params") ?? "{}"
}

get url() {
return this.getAttribute("url") ?? ""
}

get input() {
return this.getElementsByTagName("input")[0]
}
Expand Down
22 changes: 11 additions & 11 deletions app/javascript/alchemy_admin/components/spinner.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"

class Spinner extends AlchemyHTMLElement {
static properties = {
size: { default: "medium" },
color: { default: "currentColor" }
}

render() {
class Spinner extends HTMLElement {
connectedCallback() {
this.className = `spinner spinner--${this.size}`

return `
this.innerHTML = `
<svg width="100%" viewBox="0 0 28 28" style="--spinner-color: ${this.color}">
<path
class="hex1"
Expand All @@ -26,6 +18,14 @@ class Spinner extends AlchemyHTMLElement {
</svg>
`
}

get size() {
return this.getAttribute("size") || "medium"
}

get color() {
return this.getAttribute("color") || "currentColor"
}
}

customElements.define("alchemy-spinner", Spinner)
Loading
Loading