administratum 0.2.4 → 0.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.
- checksums.yaml +4 -4
- data/DESIGN.md +33 -3
- data/README.md +69 -3
- data/app/assets/builds/administratum.css +1 -1
- data/app/assets/javascripts/administratum.js +429 -3
- data/app/assets/tailwind/administratum/components.css +36 -3
- data/app/assets/tailwind/administratum/theme.css +13 -3
- data/app/controllers/administratum/resource.rb +24 -1
- data/app/helpers/administratum/application_helper.rb +1 -0
- data/app/helpers/administratum/form_helper.rb +56 -4
- data/app/helpers/administratum/reorder_helper.rb +55 -0
- data/app/views/administratum/fields/_belongs_to.html.erb +2 -1
- data/app/views/administratum/fields/_select.html.erb +2 -1
- data/app/views/administratum/resources/_table.html.erb +16 -3
- data/config/locales/en.yml +14 -0
- data/lib/administratum/definition.rb +2 -1
- data/lib/administratum/dsl.rb +9 -0
- data/lib/administratum/reorder.rb +33 -0
- data/lib/administratum/version.rb +1 -1
- data/lib/administratum.rb +1 -0
- metadata +3 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Administratum runtime: islands, offcanvas panels, dropdowns,
|
|
2
|
-
// flash messages and the light/dark toggle. Plain ES module, no dependencies.
|
|
1
|
+
// Administratum runtime: islands, offcanvas panels, dropdowns, comboboxes,
|
|
2
|
+
// sortable lists, confirmations, flash messages and the light/dark toggle. Plain ES module, no dependencies.
|
|
3
3
|
// Behaviour is attached with event delegation, so it survives Turbo page swaps.
|
|
4
4
|
|
|
5
5
|
function install() {
|
|
@@ -139,6 +139,420 @@ function scheduleFlashDismissal(root = document) {
|
|
|
139
139
|
})
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// -------------------------------------------------------------- comboboxes --
|
|
143
|
+
|
|
144
|
+
// <select data-adm-combobox> becomes a type-to-filter input. The select stays
|
|
145
|
+
// in the form (visually hidden) and keeps submitting its param. With
|
|
146
|
+
// data-adm-combobox-url, options come from a JSON search endpoint instead:
|
|
147
|
+
// GET url?q=… returning [{ id, label, group }].
|
|
148
|
+
|
|
149
|
+
const COMBOBOX_SELECTOR = "select[data-adm-combobox]:not([multiple])"
|
|
150
|
+
const comboboxes = new WeakSet()
|
|
151
|
+
let comboboxCount = 0
|
|
152
|
+
|
|
153
|
+
// Case- and accent-insensitive matching: "Ange" finds "Ängel".
|
|
154
|
+
function fold(text) {
|
|
155
|
+
return text.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function enhanceComboboxes(root = document) {
|
|
159
|
+
root.querySelectorAll(COMBOBOX_SELECTOR).forEach((select) => {
|
|
160
|
+
if (comboboxes.has(select)) return
|
|
161
|
+
// A Turbo snapshot restores the input without its listeners: rebuild it.
|
|
162
|
+
const stale = select.previousElementSibling
|
|
163
|
+
if (stale && stale.matches("[data-adm-combobox-wrapper]")) stale.remove()
|
|
164
|
+
comboboxes.add(select)
|
|
165
|
+
createCombobox(select)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function createCombobox(select) {
|
|
170
|
+
const id = `adm-combobox-${++comboboxCount}`
|
|
171
|
+
const data = select.dataset
|
|
172
|
+
const url = data.admComboboxUrl
|
|
173
|
+
const minLength = Number(data.admComboboxMin || (url ? 2 : 0))
|
|
174
|
+
const texts = {
|
|
175
|
+
empty: data.admComboboxEmpty || "No matches",
|
|
176
|
+
loading: data.admComboboxLoading || "Searching…",
|
|
177
|
+
short: (data.admComboboxShort || "Type at least %{count} characters").replace("%{count}", minLength),
|
|
178
|
+
error: data.admComboboxError || "Search failed"
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const wrapper = document.createElement("div")
|
|
182
|
+
wrapper.className = "adm-combobox"
|
|
183
|
+
wrapper.dataset.admComboboxWrapper = ""
|
|
184
|
+
const input = document.createElement("input")
|
|
185
|
+
input.type = "text"
|
|
186
|
+
input.className = "adm-input adm-combobox-input"
|
|
187
|
+
input.autocomplete = "off"
|
|
188
|
+
input.spellcheck = false
|
|
189
|
+
input.disabled = select.disabled
|
|
190
|
+
input.setAttribute("role", "combobox")
|
|
191
|
+
input.setAttribute("aria-autocomplete", "list")
|
|
192
|
+
input.setAttribute("aria-expanded", "false")
|
|
193
|
+
input.setAttribute("aria-controls", `${id}-listbox`)
|
|
194
|
+
const describedBy = select.getAttribute("aria-describedby")
|
|
195
|
+
if (describedBy) input.setAttribute("aria-describedby", describedBy)
|
|
196
|
+
const blank = select.querySelector('option[value=""]')
|
|
197
|
+
if (blank && blank.textContent.trim()) input.placeholder = blank.textContent.trim()
|
|
198
|
+
const listbox = document.createElement("div")
|
|
199
|
+
listbox.id = `${id}-listbox`
|
|
200
|
+
listbox.className = "adm-combobox-listbox"
|
|
201
|
+
listbox.setAttribute("role", "listbox")
|
|
202
|
+
listbox.hidden = true
|
|
203
|
+
wrapper.append(input, listbox)
|
|
204
|
+
select.before(wrapper)
|
|
205
|
+
|
|
206
|
+
select.classList.add("adm-sr-only")
|
|
207
|
+
select.tabIndex = -1
|
|
208
|
+
select.setAttribute("aria-hidden", "true")
|
|
209
|
+
const label = select.id && document.querySelector(`label[for="${CSS.escape(select.id)}"]`)
|
|
210
|
+
if (label) {
|
|
211
|
+
label.id ||= `${id}-label`
|
|
212
|
+
input.setAttribute("aria-labelledby", label.id)
|
|
213
|
+
label.addEventListener("click", (event) => {
|
|
214
|
+
event.preventDefault()
|
|
215
|
+
input.focus()
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let results = []
|
|
220
|
+
let active = -1
|
|
221
|
+
let typed = false
|
|
222
|
+
let timer = null
|
|
223
|
+
let controller = null
|
|
224
|
+
|
|
225
|
+
const displayText = () => {
|
|
226
|
+
const option = select.selectedOptions[0]
|
|
227
|
+
return option && option.value !== "" ? option.textContent.trim() : ""
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const localOptions = () => Array.from(select.options)
|
|
231
|
+
.filter((option) => option.value !== "" && !option.disabled)
|
|
232
|
+
.map((option) => ({
|
|
233
|
+
value: option.value,
|
|
234
|
+
label: option.textContent.trim(),
|
|
235
|
+
group: option.parentElement instanceof HTMLOptGroupElement ? option.parentElement.label : ""
|
|
236
|
+
}))
|
|
237
|
+
|
|
238
|
+
// Every typed word must appear in the option's label or its group's label.
|
|
239
|
+
const filterLocal = (query) => {
|
|
240
|
+
const terms = fold(query).split(/\s+/).filter(Boolean)
|
|
241
|
+
return localOptions().filter((option) => {
|
|
242
|
+
const haystack = fold(`${option.group} ${option.label}`)
|
|
243
|
+
return terms.every((term) => haystack.includes(term))
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function setActive(index) {
|
|
248
|
+
listbox.querySelector(".adm-combobox-option-active")?.classList.remove("adm-combobox-option-active")
|
|
249
|
+
active = index
|
|
250
|
+
const option = index >= 0 ? document.getElementById(`${id}-option-${index}`) : null
|
|
251
|
+
if (!option) {
|
|
252
|
+
input.removeAttribute("aria-activedescendant")
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
option.classList.add("adm-combobox-option-active")
|
|
256
|
+
input.setAttribute("aria-activedescendant", option.id)
|
|
257
|
+
option.scrollIntoView({ block: "nearest" })
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function render(message) {
|
|
261
|
+
listbox.replaceChildren()
|
|
262
|
+
if (message) {
|
|
263
|
+
const note = document.createElement("div")
|
|
264
|
+
note.className = "adm-combobox-empty"
|
|
265
|
+
note.textContent = message
|
|
266
|
+
listbox.append(note)
|
|
267
|
+
}
|
|
268
|
+
let group = ""
|
|
269
|
+
let container = listbox
|
|
270
|
+
results.forEach((item, index) => {
|
|
271
|
+
if (item.group !== group) {
|
|
272
|
+
group = item.group
|
|
273
|
+
container = listbox
|
|
274
|
+
if (group) {
|
|
275
|
+
container = document.createElement("div")
|
|
276
|
+
container.setAttribute("role", "group")
|
|
277
|
+
container.setAttribute("aria-labelledby", `${id}-group-${index}`)
|
|
278
|
+
const heading = document.createElement("div")
|
|
279
|
+
heading.id = `${id}-group-${index}`
|
|
280
|
+
heading.className = "adm-combobox-group"
|
|
281
|
+
heading.setAttribute("role", "presentation")
|
|
282
|
+
heading.textContent = group
|
|
283
|
+
container.append(heading)
|
|
284
|
+
listbox.append(container)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const option = document.createElement("div")
|
|
288
|
+
option.id = `${id}-option-${index}`
|
|
289
|
+
option.className = "adm-combobox-option"
|
|
290
|
+
option.setAttribute("role", "option")
|
|
291
|
+
option.setAttribute("aria-selected", String(item.value === select.value))
|
|
292
|
+
option.dataset.index = index
|
|
293
|
+
option.textContent = item.label
|
|
294
|
+
container.append(option)
|
|
295
|
+
})
|
|
296
|
+
const selected = results.findIndex((item) => item.value === select.value)
|
|
297
|
+
setActive(results.length ? Math.max(selected, 0) : -1)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function show() {
|
|
301
|
+
listbox.hidden = false
|
|
302
|
+
input.setAttribute("aria-expanded", "true")
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function hide() {
|
|
306
|
+
clearTimeout(timer)
|
|
307
|
+
controller?.abort()
|
|
308
|
+
listbox.hidden = true
|
|
309
|
+
input.setAttribute("aria-expanded", "false")
|
|
310
|
+
setActive(-1)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function search(query) {
|
|
314
|
+
controller?.abort()
|
|
315
|
+
controller = new AbortController()
|
|
316
|
+
const target = new URL(url, window.location.href)
|
|
317
|
+
target.searchParams.set("q", query)
|
|
318
|
+
try {
|
|
319
|
+
const response = await fetch(target, { headers: { Accept: "application/json" }, credentials: "same-origin", signal: controller.signal })
|
|
320
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
321
|
+
const items = await response.json()
|
|
322
|
+
results = items.map((item) => ({ value: String(item.id), label: String(item.label ?? item.id), group: item.group ? String(item.group) : "" }))
|
|
323
|
+
render(results.length ? null : texts.empty)
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error.name === "AbortError") return
|
|
326
|
+
console.error("[administratum] combobox search failed", error)
|
|
327
|
+
results = []
|
|
328
|
+
render(texts.error)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Until the viewer types, the input shows the current choice and the list shows everything.
|
|
333
|
+
function update() {
|
|
334
|
+
const query = typed ? input.value.trim() : ""
|
|
335
|
+
show()
|
|
336
|
+
if (!url) {
|
|
337
|
+
results = query ? filterLocal(query) : localOptions()
|
|
338
|
+
render(results.length ? null : texts.empty)
|
|
339
|
+
} else if (query.length < minLength) {
|
|
340
|
+
results = []
|
|
341
|
+
render(texts.short)
|
|
342
|
+
} else {
|
|
343
|
+
clearTimeout(timer)
|
|
344
|
+
results = []
|
|
345
|
+
render(texts.loading)
|
|
346
|
+
timer = setTimeout(() => search(query), 250)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function choose(value, text) {
|
|
351
|
+
let option = Array.from(select.options).find((candidate) => candidate.value === value)
|
|
352
|
+
if (!option) {
|
|
353
|
+
option = new Option(text, value)
|
|
354
|
+
select.add(option)
|
|
355
|
+
}
|
|
356
|
+
if (select.value !== value) {
|
|
357
|
+
select.value = value
|
|
358
|
+
select.dispatchEvent(new Event("input", { bubbles: true }))
|
|
359
|
+
select.dispatchEvent(new Event("change", { bubbles: true }))
|
|
360
|
+
}
|
|
361
|
+
input.setCustomValidity("")
|
|
362
|
+
revert()
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function revert() {
|
|
366
|
+
typed = false
|
|
367
|
+
input.value = displayText()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function move(delta) {
|
|
371
|
+
if (!results.length) return
|
|
372
|
+
setActive(Math.min(Math.max(active + delta, 0), results.length - 1))
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
revert()
|
|
376
|
+
|
|
377
|
+
input.addEventListener("input", () => {
|
|
378
|
+
typed = true
|
|
379
|
+
input.setCustomValidity("")
|
|
380
|
+
update()
|
|
381
|
+
})
|
|
382
|
+
input.addEventListener("click", () => {
|
|
383
|
+
if (listbox.hidden) update()
|
|
384
|
+
})
|
|
385
|
+
input.addEventListener("keydown", (event) => {
|
|
386
|
+
const open = !listbox.hidden
|
|
387
|
+
switch (event.key) {
|
|
388
|
+
case "ArrowDown":
|
|
389
|
+
case "ArrowUp":
|
|
390
|
+
event.preventDefault()
|
|
391
|
+
if (!open) update()
|
|
392
|
+
else if (!event.altKey) move(event.key === "ArrowDown" ? 1 : -1)
|
|
393
|
+
break
|
|
394
|
+
case "Enter":
|
|
395
|
+
if (!open) return
|
|
396
|
+
event.preventDefault()
|
|
397
|
+
if (results[active]) choose(results[active].value, results[active].label)
|
|
398
|
+
hide()
|
|
399
|
+
break
|
|
400
|
+
// Escape closes the list (or undoes typing) before it closes a panel.
|
|
401
|
+
case "Escape":
|
|
402
|
+
if (!open && !typed) return
|
|
403
|
+
event.preventDefault()
|
|
404
|
+
hide()
|
|
405
|
+
revert()
|
|
406
|
+
break
|
|
407
|
+
}
|
|
408
|
+
})
|
|
409
|
+
// Clearing the text and leaving picks the blank option, when there is one.
|
|
410
|
+
input.addEventListener("blur", () => {
|
|
411
|
+
hide()
|
|
412
|
+
if (typed && input.value.trim() === "" && blank) choose("", "")
|
|
413
|
+
else revert()
|
|
414
|
+
})
|
|
415
|
+
listbox.addEventListener("mousedown", (event) => event.preventDefault())
|
|
416
|
+
listbox.addEventListener("click", (event) => {
|
|
417
|
+
const option = event.target instanceof Element ? event.target.closest('[role="option"]') : null
|
|
418
|
+
if (!option) return
|
|
419
|
+
const item = results[Number(option.dataset.index)]
|
|
420
|
+
choose(item.value, item.label)
|
|
421
|
+
hide()
|
|
422
|
+
})
|
|
423
|
+
// Label clicks and panel autofocus land on the hidden select: pass focus on.
|
|
424
|
+
select.addEventListener("focus", () => input.focus())
|
|
425
|
+
select.addEventListener("change", () => {
|
|
426
|
+
if (document.activeElement !== input) revert()
|
|
427
|
+
})
|
|
428
|
+
// The browser can't point at a hidden select, so report on the input.
|
|
429
|
+
select.addEventListener("invalid", (event) => {
|
|
430
|
+
event.preventDefault()
|
|
431
|
+
input.setCustomValidity(select.validationMessage)
|
|
432
|
+
input.reportValidity()
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// --------------------------------------------------------------- sortables --
|
|
437
|
+
|
|
438
|
+
// [data-adm-sortable] holds [data-adm-sortable-item] children, each with the
|
|
439
|
+
// controls from reorder_controls. Without JS those are position number fields;
|
|
440
|
+
// with it, drag handles and move up/down buttons that renumber the fields.
|
|
441
|
+
|
|
442
|
+
const sortables = new WeakSet()
|
|
443
|
+
let drag = null
|
|
444
|
+
let liveRegion = null
|
|
445
|
+
|
|
446
|
+
function sortableItems(list) {
|
|
447
|
+
return Array.from(list.children).filter((child) => child.matches("[data-adm-sortable-item]"))
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function enhanceSortables(root = document) {
|
|
451
|
+
root.querySelectorAll("[data-adm-sortable]").forEach((list) => {
|
|
452
|
+
if (sortables.has(list)) return
|
|
453
|
+
sortables.add(list)
|
|
454
|
+
list.querySelectorAll("[data-adm-sortable-handle], [data-adm-sortable-up], [data-adm-sortable-down]").forEach((el) => { el.hidden = false })
|
|
455
|
+
list.querySelectorAll("[data-adm-sortable-fallback]").forEach((el) => { el.hidden = true })
|
|
456
|
+
refreshSortable(list)
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function refreshSortable(list) {
|
|
461
|
+
const items = sortableItems(list)
|
|
462
|
+
items.forEach((item, index) => {
|
|
463
|
+
const position = item.querySelector("[data-adm-sortable-position]")
|
|
464
|
+
if (position) position.value = index + 1
|
|
465
|
+
const up = item.querySelector("[data-adm-sortable-up]")
|
|
466
|
+
if (up) up.disabled = index === 0
|
|
467
|
+
const down = item.querySelector("[data-adm-sortable-down]")
|
|
468
|
+
if (down) down.disabled = index === items.length - 1
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function announceMove(list, item) {
|
|
473
|
+
if (!liveRegion || !liveRegion.isConnected) {
|
|
474
|
+
liveRegion = document.createElement("div")
|
|
475
|
+
liveRegion.className = "adm-sr-only"
|
|
476
|
+
liveRegion.setAttribute("aria-live", "polite")
|
|
477
|
+
document.body.append(liveRegion)
|
|
478
|
+
}
|
|
479
|
+
const items = sortableItems(list)
|
|
480
|
+
const template = item.querySelector("[data-adm-sortable-moved]")?.dataset.admSortableMoved || "Moved to position %{position} of %{count}"
|
|
481
|
+
liveRegion.textContent = template.replace("%{position}", items.indexOf(item) + 1).replace("%{count}", items.length)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function sortableItemFor(control) {
|
|
485
|
+
const item = control.closest("[data-adm-sortable-item]")
|
|
486
|
+
return item && sortables.has(item.parentElement) ? item : null
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Moves an item one step and keeps focus on the control that moved it, or on
|
|
490
|
+
// its opposite button once it reaches the end of the list.
|
|
491
|
+
function moveSortable(item, delta, control) {
|
|
492
|
+
const list = item.parentElement
|
|
493
|
+
const items = sortableItems(list)
|
|
494
|
+
const target = items[items.indexOf(item) + delta]
|
|
495
|
+
if (!target) return
|
|
496
|
+
if (delta < 0) target.before(item)
|
|
497
|
+
else target.after(item)
|
|
498
|
+
refreshSortable(list)
|
|
499
|
+
const fallback = item.querySelector(delta < 0 ? "[data-adm-sortable-down]" : "[data-adm-sortable-up]")
|
|
500
|
+
;(control.disabled ? fallback : control)?.focus()
|
|
501
|
+
announceMove(list, item)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
document.addEventListener("keydown", (event) => {
|
|
505
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return
|
|
506
|
+
const handle = event.target instanceof Element ? event.target.closest("[data-adm-sortable-handle]") : null
|
|
507
|
+
const item = handle && sortableItemFor(handle)
|
|
508
|
+
if (!item) return
|
|
509
|
+
event.preventDefault()
|
|
510
|
+
moveSortable(item, event.key === "ArrowUp" ? -1 : 1, handle)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
document.addEventListener("pointerdown", (event) => {
|
|
514
|
+
if (event.button !== 0) return
|
|
515
|
+
const handle = event.target instanceof Element ? event.target.closest("[data-adm-sortable-handle]") : null
|
|
516
|
+
const item = handle && sortableItemFor(handle)
|
|
517
|
+
if (!item) return
|
|
518
|
+
event.preventDefault()
|
|
519
|
+
handle.setPointerCapture(event.pointerId)
|
|
520
|
+
item.classList.add("adm-sortable-dragging")
|
|
521
|
+
drag = { item, list: item.parentElement }
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
// The dragged item goes just before the first other item whose midpoint is
|
|
525
|
+
// below the pointer, or last when there is none.
|
|
526
|
+
document.addEventListener("pointermove", (event) => {
|
|
527
|
+
if (!drag) return
|
|
528
|
+
const { item, list } = drag
|
|
529
|
+
const items = sortableItems(list)
|
|
530
|
+
const others = items.filter((other) => other !== item)
|
|
531
|
+
const next = others.find((other) => {
|
|
532
|
+
const rect = other.getBoundingClientRect()
|
|
533
|
+
return event.clientY < rect.top + rect.height / 2
|
|
534
|
+
})
|
|
535
|
+
const current = items[items.indexOf(item) + 1]
|
|
536
|
+
if (next === current) return
|
|
537
|
+
if (next) next.before(item)
|
|
538
|
+
else others.at(-1)?.after(item)
|
|
539
|
+
refreshSortable(list)
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
function endDrag() {
|
|
543
|
+
if (!drag) return
|
|
544
|
+
drag.item.classList.remove("adm-sortable-dragging")
|
|
545
|
+
announceMove(drag.list, drag.item)
|
|
546
|
+
drag = null
|
|
547
|
+
}
|
|
548
|
+
document.addEventListener("pointerup", endDrag)
|
|
549
|
+
document.addEventListener("pointercancel", endDrag)
|
|
550
|
+
|
|
551
|
+
function enhance(root = document) {
|
|
552
|
+
enhanceComboboxes(root)
|
|
553
|
+
enhanceSortables(root)
|
|
554
|
+
}
|
|
555
|
+
|
|
142
556
|
// ------------------------------------------------------------------ events --
|
|
143
557
|
|
|
144
558
|
document.addEventListener("click", (event) => {
|
|
@@ -158,6 +572,13 @@ document.addEventListener("click", (event) => {
|
|
|
158
572
|
return
|
|
159
573
|
}
|
|
160
574
|
|
|
575
|
+
const mover = target.closest("[data-adm-sortable-up], [data-adm-sortable-down]")
|
|
576
|
+
const moved = mover && sortableItemFor(mover)
|
|
577
|
+
if (moved) {
|
|
578
|
+
moveSortable(moved, mover.matches("[data-adm-sortable-up]") ? -1 : 1, mover)
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
|
|
161
582
|
const dropdownToggle = target.closest("[data-adm-dropdown-toggle]")
|
|
162
583
|
if (dropdownToggle) {
|
|
163
584
|
const dropdown = dropdownToggle.closest("[data-adm-dropdown]")
|
|
@@ -189,8 +610,9 @@ document.addEventListener("click", (event) => {
|
|
|
189
610
|
if (dismiss) dismiss.parentElement.remove()
|
|
190
611
|
})
|
|
191
612
|
|
|
613
|
+
// A control that handled Escape itself (an open combobox) prevents the default.
|
|
192
614
|
document.addEventListener("keydown", (event) => {
|
|
193
|
-
if (event.key !== "Escape") return
|
|
615
|
+
if (event.key !== "Escape" || event.defaultPrevented) return
|
|
194
616
|
closePanels()
|
|
195
617
|
closeDropdowns()
|
|
196
618
|
})
|
|
@@ -208,11 +630,13 @@ document.addEventListener("submit", (event) => {
|
|
|
208
630
|
|
|
209
631
|
function boot() {
|
|
210
632
|
mountIslands()
|
|
633
|
+
enhance()
|
|
211
634
|
scheduleFlashDismissal()
|
|
212
635
|
}
|
|
213
636
|
|
|
214
637
|
document.addEventListener("DOMContentLoaded", boot)
|
|
215
638
|
document.addEventListener("turbo:load", boot)
|
|
639
|
+
document.addEventListener("turbo:frame-load", (event) => enhance(event.target))
|
|
216
640
|
document.addEventListener("turbo:before-render", () => {
|
|
217
641
|
closePanels()
|
|
218
642
|
unmountIslands()
|
|
@@ -233,6 +657,8 @@ Administratum.islands = {
|
|
|
233
657
|
Administratum.csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content
|
|
234
658
|
Administratum.openPanel = openPanel
|
|
235
659
|
Administratum.closePanels = closePanels
|
|
660
|
+
// Enhances comboboxes and sortable lists in HTML inserted after page load.
|
|
661
|
+
Administratum.enhance = enhance
|
|
236
662
|
|
|
237
663
|
window.Administratum = Administratum
|
|
238
664
|
|
|
@@ -142,8 +142,10 @@
|
|
|
142
142
|
select[multiple].adm-input { @apply py-1.5; }
|
|
143
143
|
/* Safari ignores padding and height on natively styled selects, so draw
|
|
144
144
|
our own: no native appearance, and a chevron made of two token-coloured
|
|
145
|
-
gradients (a data-URI SVG would need a hard-coded colour).
|
|
146
|
-
|
|
145
|
+
gradients (a data-URI SVG would need a hard-coded colour). Searchable
|
|
146
|
+
selects get the same chevron. */
|
|
147
|
+
select.adm-input:not([multiple]),
|
|
148
|
+
.adm-combobox-input {
|
|
147
149
|
@apply appearance-none bg-no-repeat pr-9;
|
|
148
150
|
background-image:
|
|
149
151
|
linear-gradient(45deg, transparent 50%, var(--color-adm-muted) 50%),
|
|
@@ -158,16 +160,47 @@
|
|
|
158
160
|
.adm-form-actions {
|
|
159
161
|
@apply flex flex-wrap items-center gap-2 border-t border-adm-border bg-adm-surface-muted px-6 py-4 sm:pl-[16.5rem];
|
|
160
162
|
}
|
|
163
|
+
/* Searchable select: administratum.js builds these around select[data-adm-combobox]. */
|
|
164
|
+
.adm-combobox { @apply relative; }
|
|
165
|
+
.adm-combobox-listbox {
|
|
166
|
+
@apply absolute inset-x-0 top-full z-30 mt-1 max-h-72 overflow-y-auto rounded-adm border border-adm-border bg-adm-surface py-1 shadow-lg;
|
|
167
|
+
}
|
|
168
|
+
.adm-combobox-group { @apply px-3.5 pt-2.5 pb-1 text-xs font-semibold tracking-wide text-adm-muted uppercase; }
|
|
169
|
+
.adm-combobox-option { @apply cursor-pointer px-3.5 py-1.5 text-adm-text; }
|
|
170
|
+
.adm-combobox-option[aria-selected="true"] { @apply font-semibold; }
|
|
171
|
+
.adm-combobox-option-active,
|
|
172
|
+
.adm-combobox-option:hover { @apply bg-adm-surface-muted; }
|
|
173
|
+
.adm-combobox-option-active { @apply shadow-[inset_2px_0_0_var(--color-adm-primary)]; }
|
|
174
|
+
.adm-combobox-empty { @apply px-3.5 py-2 text-adm-muted; }
|
|
175
|
+
|
|
176
|
+
/* Reorder controls (reorder_controls helper). */
|
|
177
|
+
.adm-sortable-controls { @apply inline-flex items-center gap-1; }
|
|
178
|
+
.adm-sortable-handle { @apply cursor-grab touch-none; }
|
|
179
|
+
.adm-sortable-controls .adm-icon-btn:disabled { @apply cursor-not-allowed opacity-40 hover:bg-transparent; }
|
|
180
|
+
.adm-sortable-position { @apply w-20; }
|
|
181
|
+
.adm-sortable-dragging { @apply relative z-10 opacity-80 shadow-lg; }
|
|
182
|
+
.adm-sortable-dragging > td { @apply bg-adm-surface-muted; }
|
|
183
|
+
|
|
161
184
|
.adm-check { @apply flex items-center gap-2 text-adm-text; }
|
|
162
185
|
.adm-code { @apply font-adm-mono text-xs; }
|
|
163
186
|
.adm-form-footer { @apply flex flex-wrap items-center gap-2; }
|
|
164
187
|
|
|
188
|
+
/* Plain adm-alert is danger, as it always was; the variants restyle it.
|
|
189
|
+
Status tints stay at 10% so their text keeps 4.5:1 (see theme.css). */
|
|
165
190
|
.adm-alert { @apply rounded-adm border border-adm-danger/40 bg-adm-danger/10 px-4 py-3 text-adm-text; }
|
|
166
191
|
.adm-alert-title { @apply mb-1 font-semibold text-adm-danger; }
|
|
167
192
|
.adm-alert ul { @apply list-disc pl-5; }
|
|
193
|
+
.adm-alert-danger { @apply border-adm-danger/40 bg-adm-danger/10; }
|
|
194
|
+
.adm-alert-danger .adm-alert-title { @apply text-adm-danger; }
|
|
195
|
+
.adm-alert-warning { @apply border-adm-warning/40 bg-adm-warning/10; }
|
|
196
|
+
.adm-alert-warning .adm-alert-title { @apply text-adm-warning; }
|
|
197
|
+
.adm-alert-info { @apply border-adm-border bg-adm-surface-muted; }
|
|
198
|
+
.adm-alert-info .adm-alert-title { @apply text-adm-text; }
|
|
168
199
|
|
|
169
200
|
.adm-badge { @apply inline-flex items-center rounded-full bg-adm-surface-muted px-2 py-0.5 text-xs font-medium text-adm-muted; }
|
|
170
|
-
.adm-badge-success { @apply bg-adm-success/
|
|
201
|
+
.adm-badge-success { @apply bg-adm-success/10 text-adm-success; }
|
|
202
|
+
.adm-badge-warning { @apply bg-adm-warning/10 text-adm-warning; }
|
|
203
|
+
.adm-badge-danger { @apply bg-adm-danger/10 text-adm-danger; }
|
|
171
204
|
|
|
172
205
|
/* Offcanvas panels ------------------------------------------------------ */
|
|
173
206
|
.adm-offcanvas { @apply fixed inset-0 z-40; }
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
--color-adm-muted: oklch(0.52 0.02 260);
|
|
15
15
|
--color-adm-primary: oklch(0.52 0.18 262);
|
|
16
16
|
--color-adm-primary-fg: oklch(0.99 0 0);
|
|
17
|
-
|
|
17
|
+
/* Status colours are used as text on their own 10% tint, so each keeps at
|
|
18
|
+
least 4.5:1 against it (and against the surface) in both themes. */
|
|
19
|
+
--color-adm-danger: oklch(0.54 0.2 25);
|
|
18
20
|
--color-adm-danger-fg: oklch(0.99 0 0);
|
|
19
|
-
--color-adm-success: oklch(0.
|
|
20
|
-
--color-adm-warning: oklch(0.
|
|
21
|
+
--color-adm-success: oklch(0.52 0.14 155);
|
|
22
|
+
--color-adm-warning: oklch(0.52 0.13 70);
|
|
21
23
|
--color-adm-ring: oklch(0.52 0.18 262 / 0.35);
|
|
22
24
|
--color-adm-overlay: oklch(0.2 0.02 260 / 0.45);
|
|
23
25
|
|
|
@@ -41,6 +43,10 @@
|
|
|
41
43
|
--color-adm-muted: oklch(0.7 0.015 260);
|
|
42
44
|
--color-adm-primary: oklch(0.68 0.15 262);
|
|
43
45
|
--color-adm-primary-fg: oklch(0.16 0.02 260);
|
|
46
|
+
--color-adm-danger: oklch(0.8 0.13 25);
|
|
47
|
+
--color-adm-danger-fg: oklch(0.16 0.02 260);
|
|
48
|
+
--color-adm-success: oklch(0.78 0.14 155);
|
|
49
|
+
--color-adm-warning: oklch(0.82 0.14 80);
|
|
44
50
|
--color-adm-ring: oklch(0.68 0.15 262 / 0.4);
|
|
45
51
|
--color-adm-overlay: oklch(0.05 0 0 / 0.6);
|
|
46
52
|
}
|
|
@@ -55,6 +61,10 @@
|
|
|
55
61
|
--color-adm-muted: oklch(0.7 0.015 260);
|
|
56
62
|
--color-adm-primary: oklch(0.68 0.15 262);
|
|
57
63
|
--color-adm-primary-fg: oklch(0.16 0.02 260);
|
|
64
|
+
--color-adm-danger: oklch(0.8 0.13 25);
|
|
65
|
+
--color-adm-danger-fg: oklch(0.16 0.02 260);
|
|
66
|
+
--color-adm-success: oklch(0.78 0.14 155);
|
|
67
|
+
--color-adm-warning: oklch(0.82 0.14 80);
|
|
58
68
|
--color-adm-ring: oklch(0.68 0.15 262 / 0.4);
|
|
59
69
|
--color-adm-overlay: oklch(0.05 0 0 / 0.6);
|
|
60
70
|
}
|
|
@@ -15,7 +15,7 @@ module Administratum
|
|
|
15
15
|
|
|
16
16
|
helper_method :definition, :resource_class, :paths, :paths_for, :parent_record, :collection_path, :new_resource_path,
|
|
17
17
|
:resource_path, :edit_resource_path, :action_path, :resource_form_path, :parent_path, :current_index_scope,
|
|
18
|
-
:index_scope_count
|
|
18
|
+
:index_scope_count, :index_reorderable?
|
|
19
19
|
|
|
20
20
|
def index
|
|
21
21
|
collection = current_index_scope ? current_index_scope.apply(scoped_collection) : scoped_collection
|
|
@@ -59,6 +59,15 @@ module Administratum
|
|
|
59
59
|
redirect_to after_destroy_path, notice: t("administratum.flash.destroyed", resource: resource_class.singular_label)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# PATCH positions[id]=n, for resources declared `reorderable`.
|
|
63
|
+
def reorder
|
|
64
|
+
reorder_records!(scoped_collection, params.fetch(:positions, {}))
|
|
65
|
+
respond_to do |format|
|
|
66
|
+
format.html { redirect_back_or_to after_destroy_path, notice: t("administratum.flash.reordered", resources: resource_class.plural_label) }
|
|
67
|
+
format.json { head :no_content }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
62
71
|
# Resources defined in the host app live outside the engine's namespace,
|
|
63
72
|
# so Rails hands them the host's url helpers. Point them back at the engine.
|
|
64
73
|
def self.inherited(subclass)
|
|
@@ -113,6 +122,20 @@ module Administratum
|
|
|
113
122
|
index_scope.apply(scoped_collection).ransack(params[:q], auth_object: :administratum).result.count
|
|
114
123
|
end
|
|
115
124
|
|
|
125
|
+
# Reorder controls show on the index while it lists every record, sorted by the position column.
|
|
126
|
+
def index_reorderable?
|
|
127
|
+
column = definition.reorder_column
|
|
128
|
+
return false unless column && @pagy && @search
|
|
129
|
+
|
|
130
|
+
@search.sorts.map { |sort| [sort.name, sort.dir] } == [[column.to_s, "asc"]] &&
|
|
131
|
+
@pagy.count > 1 && @pagy.pages == 1 && @pagy.count == scoped_collection.count
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Renumbers `relation` from a positions[id]=n payload. See Administratum::Reorder.
|
|
135
|
+
def reorder_records!(relation, positions, column: definition.reorder_column || :position)
|
|
136
|
+
Administratum::Reorder.call(relation, positions, column:)
|
|
137
|
+
end
|
|
138
|
+
|
|
116
139
|
def build_resource(attributes = {})
|
|
117
140
|
@parent ? @parent.public_send(definition.parent.association).build(attributes) : model.new(attributes)
|
|
118
141
|
end
|