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.
@@ -1,5 +1,5 @@
1
- // Administratum runtime: islands, offcanvas panels, dropdowns, confirmations,
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
- select.adm-input:not([multiple]) {
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/15 text-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
- --color-adm-danger: oklch(0.56 0.2 25);
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.58 0.14 155);
20
- --color-adm-warning: oklch(0.75 0.15 75);
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
@@ -7,5 +7,6 @@ module Administratum
7
7
  include Administratum::FormHelper
8
8
  include Administratum::ResourceHelper
9
9
  include Administratum::IslandHelper
10
+ include Administratum::ReorderHelper
10
11
  end
11
12
  end