diff --git a/app/assets/images/lookbook/drag_and_drop_anatomy.png b/app/assets/images/lookbook/drag_and_drop_anatomy.png
new file mode 100644
index 000000000000..69e95ef5dfe6
Binary files /dev/null and b/app/assets/images/lookbook/drag_and_drop_anatomy.png differ
diff --git a/app/assets/images/lookbook/sortable_lists_a.png b/app/assets/images/lookbook/sortable_lists_a.png
new file mode 100644
index 000000000000..7bd230173ffe
Binary files /dev/null and b/app/assets/images/lookbook/sortable_lists_a.png differ
diff --git a/app/assets/images/lookbook/sortable_lists_b.png b/app/assets/images/lookbook/sortable_lists_b.png
new file mode 100644
index 000000000000..600123160902
Binary files /dev/null and b/app/assets/images/lookbook/sortable_lists_b.png differ
diff --git a/app/assets/images/lookbook/sortable_lists_c.png b/app/assets/images/lookbook/sortable_lists_c.png
new file mode 100644
index 000000000000..4c3f7b29333c
Binary files /dev/null and b/app/assets/images/lookbook/sortable_lists_c.png differ
diff --git a/app/assets/images/lookbook/sortable_lists_d.png b/app/assets/images/lookbook/sortable_lists_d.png
new file mode 100644
index 000000000000..b091b3b6242a
Binary files /dev/null and b/app/assets/images/lookbook/sortable_lists_d.png differ
diff --git a/frontend/src/stimulus/controllers/dynamic/admin/type-form-configuration/drag-and-drop.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/type-form-configuration/drag-and-drop.controller.ts
index 6e3c08b89ed1..fcdae7d77d8c 100644
--- a/frontend/src/stimulus/controllers/dynamic/admin/type-form-configuration/drag-and-drop.controller.ts
+++ b/frontend/src/stimulus/controllers/dynamic/admin/type-form-configuration/drag-and-drop.controller.ts
@@ -28,6 +28,10 @@
import GenericDragAndDropController from '../../generic-drag-and-drop.controller';
+/**
+ * @deprecated Inherits the deprecated {@link GenericDragAndDropController};
+ * migrate this surface to the `sortable-lists` controller suite.
+ */
export default class extends GenericDragAndDropController {
protected buildData(el:Element, target:Element):FormData {
const data = super.buildData(el, target);
diff --git a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts
index 625a90b3042f..d4964aa69f67 100644
--- a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts
+++ b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts
@@ -42,6 +42,13 @@ interface TargetConfig {
targetId:string|null;
}
+/**
+ * @deprecated Closed to new consumers. Use the `sortable-lists` controller
+ * suite instead — or, once it lands, the declarative `BorderBoxListComponent`
+ * options that emit its contract. This controller is Dragula-based, sends
+ * index-based positions, and offers no keyboard path; its remaining call sites
+ * are being migrated. See the drag and drop pattern page in Lookbook.
+ */
export default class GenericDragAndDropController extends Controller {
static targets = ['container', 'scrollContainer'];
diff --git a/frontend/src/stimulus/controllers/dynamic/meetings/drag-and-drop.controller.ts b/frontend/src/stimulus/controllers/dynamic/meetings/drag-and-drop.controller.ts
index fe61455331be..c15ef22ad925 100644
--- a/frontend/src/stimulus/controllers/dynamic/meetings/drag-and-drop.controller.ts
+++ b/frontend/src/stimulus/controllers/dynamic/meetings/drag-and-drop.controller.ts
@@ -30,6 +30,10 @@ import GenericDragAndDropController from '../generic-drag-and-drop.controller';
import { appendCollapsedState } from '../../../helpers/meetings-helpers';
import { hasUnsavedChanges } from '../../../helpers/meetings-helpers';
+/**
+ * @deprecated Inherits the deprecated {@link GenericDragAndDropController};
+ * migrate this surface to the `sortable-lists` controller suite.
+ */
export default class extends GenericDragAndDropController {
protected override buildData(el:Element, target:Element):FormData {
const data = super.buildData(el, target);
diff --git a/lookbook/docs/01-how-to-use.md.erb b/lookbook/docs/01-how-to-use.md.erb
index d9fd9d10d0b6..3cfc7b554556 100644
--- a/lookbook/docs/01-how-to-use.md.erb
+++ b/lookbook/docs/01-how-to-use.md.erb
@@ -17,9 +17,13 @@ If you are looking for a specific object you know by name, you can also use the
### Patterns
-In the lower left, there is a section called "Patterns". In here, we will document specific decisions and patterns concerning how we approach the use of certain components, or how certain things (like dialogs or flash banners) work in OpenProject.
+In the lower left, there is a section called "Patterns". In here, we will document specific decisions and patterns concerning how we approach the use of certain components, or how certain things (like dialogs or flash banners) work in OpenProject.
-This includes behaviour and patters that explicitly do not follow the Primer approach, as well as specific UX patterns that are unique to OpenProject.
+This includes behaviour and patterns that explicitly do not follow the Primer approach, as well as specific UX patterns that are unique to OpenProject.
+
+Some patterns also include implementation guidance and live previews. The
+[Drag and drop](./patterns/drag_and_drop) page has tabs for design guidance,
+implementation scenarios and an API reference.
### Icons
diff --git a/lookbook/docs/components/border-box-list.md.erb b/lookbook/docs/10-components/border-box-list.md.erb
similarity index 100%
rename from lookbook/docs/components/border-box-list.md.erb
rename to lookbook/docs/10-components/border-box-list.md.erb
diff --git a/lookbook/docs/components/check-all.md.erb b/lookbook/docs/10-components/check-all.md.erb
similarity index 100%
rename from lookbook/docs/components/check-all.md.erb
rename to lookbook/docs/10-components/check-all.md.erb
diff --git a/lookbook/docs/components/collapsible-section.md.erb b/lookbook/docs/10-components/collapsible-section.md.erb
similarity index 100%
rename from lookbook/docs/components/collapsible-section.md.erb
rename to lookbook/docs/10-components/collapsible-section.md.erb
diff --git a/lookbook/docs/components/dialogs/danger-dialog.md.erb b/lookbook/docs/10-components/dialogs/danger-dialog.md.erb
similarity index 100%
rename from lookbook/docs/components/dialogs/danger-dialog.md.erb
rename to lookbook/docs/10-components/dialogs/danger-dialog.md.erb
diff --git a/lookbook/docs/components/dialogs/feedback-dialog-and-feedback-message.md.erb b/lookbook/docs/10-components/dialogs/feedback-dialog-and-feedback-message.md.erb
similarity index 100%
rename from lookbook/docs/components/dialogs/feedback-dialog-and-feedback-message.md.erb
rename to lookbook/docs/10-components/dialogs/feedback-dialog-and-feedback-message.md.erb
diff --git a/lookbook/docs/components/dialogs/full-page-prompt.md.erb b/lookbook/docs/10-components/dialogs/full-page-prompt.md.erb
similarity index 100%
rename from lookbook/docs/components/dialogs/full-page-prompt.md.erb
rename to lookbook/docs/10-components/dialogs/full-page-prompt.md.erb
diff --git a/lookbook/docs/components/email-updates-mode-selector.md.erb b/lookbook/docs/10-components/email-updates-mode-selector.md.erb
similarity index 100%
rename from lookbook/docs/components/email-updates-mode-selector.md.erb
rename to lookbook/docs/10-components/email-updates-mode-selector.md.erb
diff --git a/lookbook/docs/components/enterprise.md.erb b/lookbook/docs/10-components/enterprise.md.erb
similarity index 100%
rename from lookbook/docs/components/enterprise.md.erb
rename to lookbook/docs/10-components/enterprise.md.erb
diff --git a/lookbook/docs/components/expandable-text.md.erb b/lookbook/docs/10-components/expandable-text.md.erb
similarity index 100%
rename from lookbook/docs/components/expandable-text.md.erb
rename to lookbook/docs/10-components/expandable-text.md.erb
diff --git a/lookbook/docs/components/headers/page-header.md.erb b/lookbook/docs/10-components/headers/page-header.md.erb
similarity index 100%
rename from lookbook/docs/components/headers/page-header.md.erb
rename to lookbook/docs/10-components/headers/page-header.md.erb
diff --git a/lookbook/docs/components/headers/page-heading.md.erb b/lookbook/docs/10-components/headers/page-heading.md.erb
similarity index 100%
rename from lookbook/docs/components/headers/page-heading.md.erb
rename to lookbook/docs/10-components/headers/page-heading.md.erb
diff --git a/lookbook/docs/components/headers/sub-header.md.erb b/lookbook/docs/10-components/headers/sub-header.md.erb
similarity index 100%
rename from lookbook/docs/components/headers/sub-header.md.erb
rename to lookbook/docs/10-components/headers/sub-header.md.erb
diff --git a/lookbook/docs/components/hover-cards.md.erb b/lookbook/docs/10-components/hover-cards.md.erb
similarity index 100%
rename from lookbook/docs/components/hover-cards.md.erb
rename to lookbook/docs/10-components/hover-cards.md.erb
diff --git a/lookbook/docs/components/inset-box.md.erb b/lookbook/docs/10-components/inset-box.md.erb
similarity index 100%
rename from lookbook/docs/components/inset-box.md.erb
rename to lookbook/docs/10-components/inset-box.md.erb
diff --git a/lookbook/docs/components/loading-indicators.md.erb b/lookbook/docs/10-components/loading-indicators.md.erb
similarity index 100%
rename from lookbook/docs/components/loading-indicators.md.erb
rename to lookbook/docs/10-components/loading-indicators.md.erb
diff --git a/lookbook/docs/components/quick-filters.md.erb b/lookbook/docs/10-components/quick-filters.md.erb
similarity index 100%
rename from lookbook/docs/components/quick-filters.md.erb
rename to lookbook/docs/10-components/quick-filters.md.erb
diff --git a/lookbook/docs/components/select-panel.md.erb b/lookbook/docs/10-components/select-panel.md.erb
similarity index 100%
rename from lookbook/docs/components/select-panel.md.erb
rename to lookbook/docs/10-components/select-panel.md.erb
diff --git a/lookbook/docs/components/side-panel.md.erb b/lookbook/docs/10-components/side-panel.md.erb
similarity index 100%
rename from lookbook/docs/components/side-panel.md.erb
rename to lookbook/docs/10-components/side-panel.md.erb
diff --git a/lookbook/docs/components/status_button.md.erb b/lookbook/docs/10-components/status_button.md.erb
similarity index 100%
rename from lookbook/docs/components/status_button.md.erb
rename to lookbook/docs/10-components/status_button.md.erb
diff --git a/lookbook/docs/components/tables/border-box-table.md.erb b/lookbook/docs/10-components/tables/border-box-table.md.erb
similarity index 100%
rename from lookbook/docs/components/tables/border-box-table.md.erb
rename to lookbook/docs/10-components/tables/border-box-table.md.erb
diff --git a/lookbook/docs/components/tables/tables.md.erb b/lookbook/docs/10-components/tables/tables.md.erb
similarity index 100%
rename from lookbook/docs/components/tables/tables.md.erb
rename to lookbook/docs/10-components/tables/tables.md.erb
diff --git a/lookbook/docs/components/tree-view/filterable-tree-view.md.erb b/lookbook/docs/10-components/tree-view/filterable-tree-view.md.erb
similarity index 100%
rename from lookbook/docs/components/tree-view/filterable-tree-view.md.erb
rename to lookbook/docs/10-components/tree-view/filterable-tree-view.md.erb
diff --git a/lookbook/docs/components/tree-view/tree-view.md.erb b/lookbook/docs/10-components/tree-view/tree-view.md.erb
similarity index 100%
rename from lookbook/docs/components/tree-view/tree-view.md.erb
rename to lookbook/docs/10-components/tree-view/tree-view.md.erb
diff --git a/lookbook/docs/components/work-packages/card.md.erb b/lookbook/docs/10-components/work-packages/card.md.erb
similarity index 100%
rename from lookbook/docs/components/work-packages/card.md.erb
rename to lookbook/docs/10-components/work-packages/card.md.erb
diff --git a/lookbook/docs/components/work-packages/work-package-info-line.md.erb b/lookbook/docs/10-components/work-packages/work-package-info-line.md.erb
similarity index 100%
rename from lookbook/docs/components/work-packages/work-package-info-line.md.erb
rename to lookbook/docs/10-components/work-packages/work-package-info-line.md.erb
diff --git a/lookbook/docs/components/work-packages/work-package-split-view.md.erb b/lookbook/docs/10-components/work-packages/work-package-split-view.md.erb
similarity index 100%
rename from lookbook/docs/components/work-packages/work-package-split-view.md.erb
rename to lookbook/docs/10-components/work-packages/work-package-split-view.md.erb
diff --git a/lookbook/docs/patterns/01-layout.md.erb b/lookbook/docs/20-patterns/01-layout.md.erb
similarity index 100%
rename from lookbook/docs/patterns/01-layout.md.erb
rename to lookbook/docs/20-patterns/01-layout.md.erb
diff --git a/lookbook/docs/patterns/02-forms/01-index.md.erb b/lookbook/docs/20-patterns/02-forms/01-index.md.erb
similarity index 100%
rename from lookbook/docs/patterns/02-forms/01-index.md.erb
rename to lookbook/docs/20-patterns/02-forms/01-index.md.erb
diff --git a/lookbook/docs/patterns/02-forms/02-usage.md.erb b/lookbook/docs/20-patterns/02-forms/02-usage.md.erb
similarity index 100%
rename from lookbook/docs/patterns/02-forms/02-usage.md.erb
rename to lookbook/docs/20-patterns/02-forms/02-usage.md.erb
diff --git a/lookbook/docs/patterns/02-forms/03-interactivity.md.erb b/lookbook/docs/20-patterns/02-forms/03-interactivity.md.erb
similarity index 100%
rename from lookbook/docs/patterns/02-forms/03-interactivity.md.erb
rename to lookbook/docs/20-patterns/02-forms/03-interactivity.md.erb
diff --git a/lookbook/docs/patterns/02-forms/04-admin-settings.md.erb b/lookbook/docs/20-patterns/02-forms/04-admin-settings.md.erb
similarity index 100%
rename from lookbook/docs/patterns/02-forms/04-admin-settings.md.erb
rename to lookbook/docs/20-patterns/02-forms/04-admin-settings.md.erb
diff --git a/lookbook/docs/patterns/02-forms/05-rich-text-editor.md.erb b/lookbook/docs/20-patterns/02-forms/05-rich-text-editor.md.erb
similarity index 100%
rename from lookbook/docs/patterns/02-forms/05-rich-text-editor.md.erb
rename to lookbook/docs/20-patterns/02-forms/05-rich-text-editor.md.erb
diff --git a/lookbook/docs/patterns/02-forms/06-best-practices.md.erb b/lookbook/docs/20-patterns/02-forms/06-best-practices.md.erb
similarity index 100%
rename from lookbook/docs/patterns/02-forms/06-best-practices.md.erb
rename to lookbook/docs/20-patterns/02-forms/06-best-practices.md.erb
diff --git a/lookbook/docs/patterns/03-autocompleters.md.erb b/lookbook/docs/20-patterns/03-autocompleters.md.erb
similarity index 100%
rename from lookbook/docs/patterns/03-autocompleters.md.erb
rename to lookbook/docs/20-patterns/03-autocompleters.md.erb
diff --git a/lookbook/docs/patterns/04-links.md.erb b/lookbook/docs/20-patterns/04-links.md.erb
similarity index 100%
rename from lookbook/docs/patterns/04-links.md.erb
rename to lookbook/docs/20-patterns/04-links.md.erb
diff --git a/lookbook/docs/patterns/05-dialogs.md.erb b/lookbook/docs/20-patterns/05-dialogs.md.erb
similarity index 100%
rename from lookbook/docs/patterns/05-dialogs.md.erb
rename to lookbook/docs/20-patterns/05-dialogs.md.erb
diff --git a/lookbook/docs/patterns/06-inplace-edit-fields.md.erb b/lookbook/docs/20-patterns/06-inplace-edit-fields.md.erb
similarity index 100%
rename from lookbook/docs/patterns/06-inplace-edit-fields.md.erb
rename to lookbook/docs/20-patterns/06-inplace-edit-fields.md.erb
diff --git a/lookbook/docs/patterns/07-menu-and-navigation.md.erb b/lookbook/docs/20-patterns/07-menu-and-navigation.md.erb
similarity index 100%
rename from lookbook/docs/patterns/07-menu-and-navigation.md.erb
rename to lookbook/docs/20-patterns/07-menu-and-navigation.md.erb
diff --git a/lookbook/docs/patterns/08-flash-banner.md.erb b/lookbook/docs/20-patterns/08-flash-banner.md.erb
similarity index 100%
rename from lookbook/docs/patterns/08-flash-banner.md.erb
rename to lookbook/docs/20-patterns/08-flash-banner.md.erb
diff --git a/lookbook/docs/patterns/09-flash-modal.md.erb b/lookbook/docs/20-patterns/09-flash-modal.md.erb
similarity index 100%
rename from lookbook/docs/patterns/09-flash-modal.md.erb
rename to lookbook/docs/20-patterns/09-flash-modal.md.erb
diff --git a/lookbook/docs/patterns/10-contextual-action-menu.md.erb b/lookbook/docs/20-patterns/10-contextual-action-menu.md.erb
similarity index 100%
rename from lookbook/docs/patterns/10-contextual-action-menu.md.erb
rename to lookbook/docs/20-patterns/10-contextual-action-menu.md.erb
diff --git a/lookbook/docs/patterns/11-filter-forms.md.erb b/lookbook/docs/20-patterns/11-filter-forms.md.erb
similarity index 100%
rename from lookbook/docs/patterns/11-filter-forms.md.erb
rename to lookbook/docs/20-patterns/11-filter-forms.md.erb
diff --git a/lookbook/docs/patterns/17-settings-pages.md.erb b/lookbook/docs/20-patterns/17-settings-pages.md.erb
similarity index 100%
rename from lookbook/docs/patterns/17-settings-pages.md.erb
rename to lookbook/docs/20-patterns/17-settings-pages.md.erb
diff --git a/lookbook/docs/patterns/20-attribute-help-texts.md.erb b/lookbook/docs/20-patterns/20-attribute-help-texts.md.erb
similarity index 100%
rename from lookbook/docs/patterns/20-attribute-help-texts.md.erb
rename to lookbook/docs/20-patterns/20-attribute-help-texts.md.erb
diff --git a/lookbook/docs/patterns/30-step-processes.md.erb b/lookbook/docs/20-patterns/30-step-processes.md.erb
similarity index 100%
rename from lookbook/docs/patterns/30-step-processes.md.erb
rename to lookbook/docs/20-patterns/30-step-processes.md.erb
diff --git a/lookbook/docs/20-patterns/40-drag-and-drop.md.erb b/lookbook/docs/20-patterns/40-drag-and-drop.md.erb
new file mode 100644
index 000000000000..0cbd6c5372d6
--- /dev/null
+++ b/lookbook/docs/20-patterns/40-drag-and-drop.md.erb
@@ -0,0 +1,9 @@
+Drag and drop lets users reorder rows and move them between lists. This
+page brings together the design guidance and the implementation of the
+`sortable-lists` controller suite.
+
+Start with **Design guidance** for interaction decisions. To implement drag
+and drop, choose **Implementation overview** in the [tabs below](#page-tabbed-sections),
+then **One list**, **Sibling lists** or **Nested lists** for your scenario.
+**API reference** covers the attributes, request and response contract, and
+migration from `generic-drag-and-drop`.
diff --git a/lookbook/docs/20-patterns/41-drag-and-drop[design-guidance].md.erb b/lookbook/docs/20-patterns/41-drag-and-drop[design-guidance].md.erb
new file mode 100644
index 000000000000..5964ee5b54c1
--- /dev/null
+++ b/lookbook/docs/20-patterns/41-drag-and-drop[design-guidance].md.erb
@@ -0,0 +1,74 @@
+---
+label: Design guidance
+---
+
+Drag and drop lets a user change the order of rows, or move a row into a
+neighboring list, by picking it up and releasing it where it belongs. In
+OpenProject it is always paired with a move menu, so the same change is
+reachable from the keyboard and with a screen reader. For implementation
+details, choose **Implementation overview** in the [tabs above](#page-tabbed-sections),
+then the scenario that matches your list.
+
+## Anatomy
+
+" alt="A list of four rows with drag handles; the second row is dimmed while its preview floats beside the list and a line between the third and fourth rows marks where it will land" width="636" style="max-width: 100%">
+
+1. **Drag handle**: the grip at the start of a list row. A drag starts here,
+ nowhere else on the row. A card has no grip: the whole card is its handle.
+2. **Drag source**: the row being moved. It stays in place, dimmed, until the
+ drop.
+3. **Drag preview**: the copy of the row that follows the pointer.
+4. **Drop indicator**: the line marking where the row will land. It marks a
+ spot without reserving space.
+5. **Drop container**: the outline on the list that will receive the row.
+
+## When to use it
+
+Use drag and drop when the order of a list is itself the setting: the
+position of an enumeration value, the sequence of sections on a form, the
+position of work packages in a sprint. Do not add it to lists whose order
+is derived (sorted by name or date) or where a single row moves rarely; a
+move menu alone serves those.
+
+## Where it is used today
+
+* Admin lists with a persisted order: enumerations, text transform actions,
+ project phase definitions, export templates, work package types.
+* Project Attributes settings: sections reorder, and custom fields reorder
+ within a section or move to another.
+* Backlogs: work packages reorder within the inbox and sprints and move
+ between them.
+
+## What users can expect
+
+* **A visible drag handle** at the start of a list row. The row is picked up
+ from the handle, not from anywhere on it, so links and buttons inside the
+ row keep working. A card (Backlogs) shows no grip and is picked up from
+ anywhere on it except its links and buttons.
+* **A drop indicator** that marks where the row will land, and a highlight
+ on the list that will receive it.
+* **A move menu** in the row's action menu ("Move to top", "Move up", "Move
+ down", "Move to bottom") offering only the directions that apply.
+* **An announcement** for screen-reader users when a row lands in another
+ list, naming that list.
+* **Rows that cannot move** still show a position and can be dropped next
+ to; they simply refuse to be picked up, or refuse to leave their list.
+* **Selecting several rows and moving them together** is being added for
+ Backlogs ([AGILE-278](https://community.openproject.org/wp/AGILE-278)).
+
+## Vocabulary
+
+Design and review discussions use one set of terms: *drag source* (the
+original row), *drag preview* (what follows the pointer), *drop indicator*
+(the line marking the landing spot), *drop container* (the highlighted
+receiving list), *reorder* (within a list) and *move* (between lists).
+Avoid "mirror", "placeholder" and "container" for these; they meant other
+things in the previous implementation.
+
+## Further reading
+
+The implementation is built on Atlassian's Pragmatic drag and drop, whose
+design guidance applies here too:
+
+* [Web platform design constraints](https://atlassian.design/components/pragmatic-drag-and-drop/web-platform-design-constraints)
+* [Accessibility guidelines](https://atlassian.design/components/pragmatic-drag-and-drop/accessibility-guidelines)
diff --git a/lookbook/docs/20-patterns/42-drag-and-drop[implementation-overview].md.erb b/lookbook/docs/20-patterns/42-drag-and-drop[implementation-overview].md.erb
new file mode 100644
index 000000000000..b70bbf61b52f
--- /dev/null
+++ b/lookbook/docs/20-patterns/42-drag-and-drop[implementation-overview].md.erb
@@ -0,0 +1,105 @@
+---
+label: Implementation overview
+---
+
+Server-rendered lists become sortable through the `sortable-lists` Stimulus
+controller suite. A drag or a move menu action sends one request describing
+where the row landed; the server persists the order and streams the list
+back. The same suite drives a single admin list, the Backlogs board and the
+nested Project Attributes settings.
+
+This page documents the markup and HTTP contract for wiring the controllers
+by hand. The **Design guidance** tab covers when drag and drop is appropriate
+and what users expect from it.
+
+## Before you wire anything
+
+**Prefer the component API where it exists.** `BorderBoxListComponent` is
+gaining `sortable_list:` / `sortable_item:` options that emit this contract
+for you ([DREAM-805](https://community.openproject.org/wp/DREAM-805)). Once
+those land they cover a single list and sibling lists of rows the server can
+reorder. Wire by hand only for what they do not reach: several item types
+under one root, per-type move endpoints, non-`BorderBoxList` markup such as
+table rows or cards, or optimistic placement.
+
+**Do not use `generic-drag-and-drop`.** The Dragula-based controller is
+deprecated and closed to new consumers. The API reference tab has a migration
+table for its remaining call sites.
+
+## Anatomy
+
+Four controllers divide the work and find each other through Stimulus
+outlets, never by walking the DOM:
+
+| Controller | Placed on | Owns |
+| --- | --- | --- |
+| `sortable-lists` | one wrapper per page region | the move URL, the request, rollback on failure, the outlet selectors for the three children |
+| `sortable-lists--list` | each list | the list's type, its id, which item type it accepts, where a drop on empty space lands |
+| `sortable-lists--item` | each row that can move | the row's id, type and accessible label; the drag handle and preview; the move menu actions |
+| `sortable-lists--scrollable` | whatever element scrolls | auto-scrolling while a drag hovers its edge |
+
+### A single list
+
+" alt="One root, one list, three rows; the root reaches the list and each row through outlets" width="460" style="max-width: 100%">
+
+The minimum topology. Three controllers; the root reaches the other two
+through outlets, never by traversing the DOM.
+
+### Sibling lists in scrolling columns
+
+" alt="Two scroll columns under one root, each holding lists of the same type; the scrollable sits between root and lists" width="636" style="max-width: 100%">
+
+Two scrolling columns, each holding several lists of one type. The
+scrollables sit inside the root and are reached by outlet like every other
+child; the root still addresses every item directly, however deep it sits.
+
+### How the root reaches its children
+
+" alt="The root's three outlet selectors resolving to the list, item and scrollable controllers" width="760" style="max-width: 100%">
+
+Nesting is DOM structure; this is how the controllers address each other.
+The root declares one outlet per child type, so it holds a live collection
+of each with no `closest()` and no `querySelector` walking. The selectors
+are ordinary descendant selectors scoped to the root's own wrapper id, so
+two roots on one page cannot capture each other's children:
+
+```html
+
+```
+
+Because the selectors match at any depth, one root holds every list and
+every item beneath it in one flat collection. A row's owning list is the
+innermost list element that contains it. That single rule is what lets one
+root serve nested lists as well as flat ones.
+
+### Vocabulary
+
+* **Sortable list, sortable item, sortable item type**: a list whose order the
+ server persists, a row that takes part in that order, and the category a
+ list accepts. A row is an item once it carries
+ `data-sortable-lists--item-id-value`; that is what counts it and anchors
+ its neighbors' drops. The `sortable-lists--item` controller on it is what
+ makes it draggable and gives it a move menu, and `confined` keeps a
+ draggable item in its own list.
+* **Drag source**: the original row while it is being dragged. **Drag
+ preview**: the copy that follows the pointer.
+* **Drop indicator**: the line marking where the row will land, drawn without
+ reserving space. **Drop container**: the list-level outline shown while a
+ compatible row hovers a list (`data-drop-container="active"`).
+* **Reorder** happens within one list; a **move** changes list. In terms of
+ the backend request they are identical.
+
+## Choosing a scenario
+
+* **One list**: rows reorder within a single list, such as admin enumerations
+ or text transform actions.
+* **Sibling lists**: several lists of the same kind, with rows moving between
+ them, as in Backlogs.
+* **Nested lists**: rows that are themselves lists, such as Project Attributes
+ sections and their custom fields.
+* **API reference**: every attribute, the backend request, the server half, the
+ Angular counterpart, migration.
diff --git a/lookbook/docs/20-patterns/43-drag-and-drop[one-list].md.erb b/lookbook/docs/20-patterns/43-drag-and-drop[one-list].md.erb
new file mode 100644
index 000000000000..bdc3080dd8fe
--- /dev/null
+++ b/lookbook/docs/20-patterns/43-drag-and-drop[one-list].md.erb
@@ -0,0 +1,150 @@
+---
+label: One list
+---
+
+One list whose rows the server can reorder. `Admin::TextTransformActions` is
+the smallest complete implementation in the codebase; the samples below
+follow it.
+
+<%= embed Patterns::SortableListsPreview, :single_list, panels: %i[source] %>
+
+The preview's move URL points at a path that does not exist, so a drop moves
+the row, the request fails, and the row rolls back with an error toast. That
+is the whole failure path; persistence needs the endpoint described at the
+end of this tab.
+
+## The root
+
+One wrapper per page region. It carries the move URL template and the outlet
+selectors, scoped to its own id:
+
+```html
+
+```
+
+Build the template from the route helper through a sentinel, so an
+installation under a relative URL root keeps working; `{id}` is expanded on
+the client with the dragged row's id:
+
+```ruby
+def move_url_template
+ id_placeholder = "__id__"
+ drop_admin_text_transform_action_path(id_placeholder).sub(id_placeholder, "{id}")
+end
+```
+
+The expanded URL must stay same-origin and relative; only path, query and
+fragment survive.
+
+## The list
+
+The list element declares the type it persists and the type it accepts.
+`type` doubles as the `list_type` the server receives, so a list without one
+cannot accept drops and warns in the console. `name` is what screen readers
+hear when a row arrives from another list; give it even for one list, it
+costs nothing and the list may gain siblings.
+
+```html
+
">
+
+ …rows…
+
+
+```
+
+Rows may sit in a child `
`, as `BorderBoxList` renders them, or directly
+under the list element. Both resolve without configuration, so table rows
+and cards need no special markup.
+
+## The row
+
+Each row declares its identity and hosts the drag handle:
+
+```html
+
+
+ …
+
+```
+
+`Primer::OpenProject::DragHandle` renders that button for you; give it the
+`handle` target.
+
+Without a `handle` target the whole row is the handle, which fights with
+links and buttons inside it. Add a `preview` target on an inner element when
+the row's own box is not what should follow the pointer (a card inside a
+padded `
`, for instance); the default preview is the row itself.
+
+## The move menu
+
+A drag is a pointer gesture, so every sortable row also needs a keyboard-
+and screen-reader-reachable path. Put move actions in the row's Primer
+`ActionMenu`; the controller depends on that component. Each action is a
+`moveItem` target with a `direction` param (`top`, `up`, `down`, `bottom`)
+and the `move` action, passed to `with_item` so the attributes land on the
+menu's `
`, not on the button inside it:
+
+```erb
+<%%= render(Primer::Alpha::ActionMenu.new) do |menu| %>
+ <%% menu.with_show_button(icon: "kebab-horizontal", scheme: :invisible, "aria-label": action.label) %>
+ <%% menu.with_divider(data: { sortable_lists__item_target: "moveDivider" }) %>
+ <%% menu.with_item(
+ label: "Move up",
+ tag: :button,
+ data: {
+ sortable_lists__item_target: "moveItem",
+ sortable_lists__item_direction_param: "up",
+ action: "click->sortable-lists--item#move"
+ }
+ ) %>
+<%% end %>
+```
+
+`move` looks up the row's `` element and asks it whether that
+`
` is disabled or hidden before moving anything; the click reaches the
+`
` by bubbling up from the button. A row without an `ActionMenu` still
+drags, its `move` action just does nothing. The `moveDivider` target on the
+divider above the group lets the controller hide it together with the
+actions when none applies.
+
+Availability is recomputed every time the menu opens: the first row has no
+"up", the last no "down". Unavailable actions are hidden; set
+`data-sortable-lists--item-hide-unavailable-value="false"` on the row to
+disable them instead.
+
+## The backend request
+
+A drop, or a move action, sends `PUT` to the expanded move URL with
+`responseKind: 'turbo-stream'` and three form fields:
+
+| Field | Value for one list |
+| --- | --- |
+| `list_type` | the list's `type` |
+| `list_id` | blank; the list declares no `id` |
+| `prev_id` | id of the row the moved row now follows, or blank when it landed first |
+
+`prev_id` is an anchor, not an index: a stale ordinal cannot silently move
+the wrong row. A drop that leaves the row where it was sends nothing.
+
+The row moves in the DOM before the request resolves. The endpoint answers
+by streaming the re-rendered list, and the morph replaces the optimistic
+placement with the server's order. A failure rolls the row back; a `422`
+stays silent because the server has already streamed its own message, any
+other status raises a generic toast.
+
+For `acts_as_list` models the endpoint is a few lines with the
+`Lists::MoveAfterAnchor` concern; the API reference tab describes the server
+half, including the anchor rules and what to do when the model orders by
+something other than a position column.
diff --git a/lookbook/docs/20-patterns/44-drag-and-drop[sibling-lists].md.erb b/lookbook/docs/20-patterns/44-drag-and-drop[sibling-lists].md.erb
new file mode 100644
index 000000000000..2b282c2e4df1
--- /dev/null
+++ b/lookbook/docs/20-patterns/44-drag-and-drop[sibling-lists].md.erb
@@ -0,0 +1,125 @@
+---
+label: Sibling lists
+---
+
+Several lists of one kind under one root, with rows moving between them.
+Backlogs is the reference: an inbox and any number of sprint columns, all
+holding work packages. Everything from the One list tab still applies; this
+tab adds only what siblings need.
+
+<%= embed Patterns::SortableListsPreview, :sibling_lists, panels: %i[source] %>
+
+As on the One list tab, the preview's move URL points at a path that does
+not exist: every drop and every move action rolls back with an error toast.
+
+## Give every list an id
+
+The lists accept the same item type, so each needs an `id` to tell them
+apart; that value is the `list_id` the server receives. Their own `type` is
+the kind of destination the server decodes and may differ between siblings:
+the Backlogs inbox declares `inbox`, every sprint column `sprint`, and both
+accept `work_package`:
+
+```html
+
+```
+
+A move into another list is the ordinary request with that list's
+`list_type` and `list_id`; the client sends nothing extra to relocate a row.
+On the server a
+change of list is a reparent plus an insert, so run both in one transaction
+and lock both lists: a failed insert must leave the record where it started.
+
+`name` is announced to screen readers when a row arrives from another list
+("moved to Sprint 3"), so it must be the user-facing name.
+
+## Keyboard path between lists
+
+The move menu from the One list tab only reorders within the row's current
+list; its four directions never change the list. A keyboard user still has
+to be able to change list, so the consumer adds its own actions that send
+the same `PUT` with the destination's `list_type` and `list_id` and no
+`prev_id`, landing the row first. Backlogs'
+`WorkPackageCardMenuComponent` shows both shapes: "Move to inbox" is a form
+item submitting `list_type=inbox` directly, while "Move to sprint…" and
+"Move to backlog…" open a dialog to pick the destination and then submit the
+same request. The preview above has no such action, so its cross-list move
+is drag-only.
+
+## Drops on empty space
+
+An empty list is still a drop target, because the list element accepts the
+drop, not its rows. A drop on the empty space of a non-empty list lands at
+the end by default; set `data-sortable-lists--list-drop-position-value="start"`
+on lists that read newest-first, as the Backlogs inbox does.
+
+## Rows that may not leave
+
+Set `data-sortable-lists--item-confined-value="true"` on a row the server
+will reorder in place but refuses to relocate. It stays fully draggable and
+its own list accepts it, so reordering keeps working; every other list
+refuses the drop and a release there leaves the row where it was.
+
+That is different from a row that may not move at all. Backlogs renders a
+work package the user may not sort as a plain row: no `sortable-lists--item`
+controller and no item id, so it neither drags nor anchors its neighbors'
+drops.
+
+## Auto-scrolling columns
+
+Put `sortable-lists--scrollable` on the element that actually scrolls, which
+is rarely the list itself. Backlogs wraps each column of lists in one:
+
+```html
+
+```
+
+Add the scrollable outlet to the root alongside the other two, with the same
+id scoping:
+
+```html
+data-sortable-lists-sortable-lists--scrollable-outlet="#backlogs_container [data-controller~='sortable-lists--scrollable']"
+```
+
+`allowedAxis` accepts `vertical` (default), `horizontal` or `all`;
+`maxScrollSpeed` accepts `standard` (default) or `fast`.
+
+## Optimistic placement
+
+By default the endpoint streams the re-rendered list back and the morph
+reconciles the row's position. A board that re-renders on every drag pays
+for that with flicker, so the root can ask for an event-only answer:
+
+```html
+
+```
+
+(plus the outlet selectors from the One list tab.)
+
+This appends `optimistic=true` to the move URL. The endpoint may then
+acknowledge with an event alone and leave the client's placement standing.
+Backlogs is the only surface that opts in today.
+
+The flag is a request, not a promise. Backlogs skips the re-render only for a
+same-list move whose requested anchor is verifiably the persisted one: a
+blank `prev_id` with nothing above the row, or a non-blank one that really is
+the row above. A cross-list move, or an anchor that has meanwhile left the
+list, reloads the frame, because the persisted order and the optimistic order
+have diverged. A failed request still rolls the rows back, unless a
+concurrent morph has already moved them, in which case the fresher server
+state wins.
+
+## Batch selection
+
+Selecting several rows and moving them together is in development
+([AGILE-278](https://community.openproject.org/wp/AGILE-278),
+[AGILE-361](https://community.openproject.org/wp/AGILE-361)). The root's
+`selection_enabled` value and the collection move endpoint it drives are not
+yet a stable contract; this section documents them once they settle.
diff --git a/lookbook/docs/20-patterns/45-drag-and-drop[nested-lists].md.erb b/lookbook/docs/20-patterns/45-drag-and-drop[nested-lists].md.erb
new file mode 100644
index 000000000000..c1095a84c3dd
--- /dev/null
+++ b/lookbook/docs/20-patterns/45-drag-and-drop[nested-lists].md.erb
@@ -0,0 +1,105 @@
+---
+label: Nested lists
+---
+
+Rows that are themselves lists: chapters that reorder, each holding articles
+that reorder within their chapter and move to another. Project Attributes
+(`Settings::ProjectCustomFieldSections`) is the reference. This is the
+advanced case; the six rules below are all it adds to the previous tabs.
+
+## Anatomy of a nested root
+
+" alt="One root holding a chapter list; each chapter row hosts its own article list; one move endpoint per item type" width="636" style="max-width: 100%">
+
+Chapters reorder among themselves; articles reorder within a chapter and
+move between chapters. Still one root: the second level comes from a second
+item type, never from a second root. The colors match the diagrams in
+**Implementation overview**; the same four controllers, only the types differ.
+
+**One root, two item types, never nested roots.** Depth comes from a second
+item type under the same root. The root's outlet selectors match at any
+depth, so it already sees both levels; a row belongs to the innermost list
+that contains it.
+
+```
+[sortable-lists] root
+ └ [sortable-lists--list] type: chapter · accepted_type: chapter
+ └ [sortable-lists--item] a chapter, as a row of the chapter list
+ └ [sortable-lists--list] the same chapter, as a list of articles
+ type: article · accepted_type: article · id:
+ └ [sortable-lists--item] an article
+```
+
+**The item and the list it hosts are different elements.** The chapter row
+wraps the article list, usually with ordinary layout markup between them.
+
+```html
+
+```
+
+Inside `ChapterComponent` the same chapter becomes a list:
+
+```html
+
+
+
+ <%%= chapter.name %>
+
+
…article rows…
+
+```
+
+**`accepted_type` keeps the levels apart.** Every list under the root is a
+candidate target for every dragged row; the accepted type is the only thing
+stopping a chapter from being dropped into a list of articles. A list
+without one accepts nothing.
+
+**One move endpoint per item type.** The root supplies `move_url_templates`,
+a JSON object keyed by item type, instead of `move_url_template`. The
+dragged row's type keys the lookup, because the endpoint belongs to the
+record being moved. `move_url_template` remains the fallback for a type
+without its own entry.
+
+```ruby
+def move_url_templates
+ id_placeholder = "__id__"
+ {
+ chapter: drop_chapter_path(id_placeholder).sub(id_placeholder, "{id}"),
+ article: drop_article_path(id_placeholder).sub(id_placeholder, "{id}")
+ }
+end
+```
+
+**The chapter's `handle` and `preview` live inside the article list
+element**, because that inner element is what should drag and preview; the
+outer row would drag its margins with it. Stimulus binds a target to the
+nearest ancestor controller with that identifier and skips the nested
+`sortable-lists--item` controllers of the articles, so the chapter's targets
+stay the chapter's however deep they sit, and the articles' handles never
+leak upward.
+
+**Levels need not share a row tag.** Chapter rows above are `
`s directly
+under the list element; article lists are `BorderBoxList`s with `
` rows
+in a `
`. Both resolve without configuration.
diff --git a/lookbook/docs/20-patterns/46-drag-and-drop[api-reference].md.erb b/lookbook/docs/20-patterns/46-drag-and-drop[api-reference].md.erb
new file mode 100644
index 000000000000..20355dfd2415
--- /dev/null
+++ b/lookbook/docs/20-patterns/46-drag-and-drop[api-reference].md.erb
@@ -0,0 +1,188 @@
+---
+label: API reference
+---
+
+The values, outlets, targets and actions of the four controllers, then the
+backend request, the server half, the response policy, the Angular
+counterpart and the migration table.
+
+## Attributes
+
+The suite is built on
+[Pragmatic drag and drop](https://atlassian.design/components/pragmatic-drag-and-drop/);
+links below point at its documentation where a concept is theirs.
+
+Four kinds of attribute appear on the markup, each with the standard Stimulus
+spelling:
+
+| Kind | Attribute | Example |
+| --- | --- | --- |
+| Value | `data---value`, camel case hyphenated | `data-sortable-lists--list-accepted-type-value="task"` |
+| Outlet | `data-sortable-lists--outlet` | `data-sortable-lists-sortable-lists--list-outlet="#tasks [data-controller~='sortable-lists--list']"` |
+| Target | `data--target=""` | `data-sortable-lists--item-target="handle"` |
+| Action | `data-action="->#"`, params as `data---param` | `data-action="click->sortable-lists--item#move"` |
+
+When you pass them through a Rails `data:` hash, as the previews do, write the
+controller with a double underscore and the rest with single ones:
+`sortable_lists__list_accepted_type_value:`.
+
+### `sortable-lists` (root)
+
+#### Values
+
+| Value | Type | Default | Meaning |
+| --- | --- | --- | --- |
+| `moveUrlTemplate` | string | | URI template expanded with the dragged row's id; must expand to a relative same-origin URL |
+| `moveUrlTemplates` | JSON object | | per-item-type templates keyed by the dragged row's type; `moveUrlTemplate` is the fallback |
+| `optimistic` | boolean | `false` | appends `optimistic=true` to the move URL and keeps the client's placement when the server answers with an event alone |
+
+#### Outlets
+
+| Outlet | Required | Meaning |
+| --- | --- | --- |
+| `sortable-lists--list` | yes | every list under this root |
+| `sortable-lists--item` | yes | every row under this root |
+| `sortable-lists--scrollable` | when declared | every auto-scrolling container under this root |
+
+### `sortable-lists--list`
+
+#### Values
+
+| Value | Type | Default | Meaning |
+| --- | --- | --- | --- |
+| `type` | string | | the list's own type, sent as `list_type`; a list without one accepts no drops |
+| `acceptedType` | string | | the item type this list accepts; omit to make the list display-only ([typing data](https://atlassian.design/components/pragmatic-drag-and-drop/core-package/recipes/typing-data)) |
+| `id` | string | | sent as `list_id`; required as soon as two lists share a type |
+| `name` | string | | announced to screen readers on a cross-list move |
+| `dropPosition` | `start` or `end` | `end` | where a drop on the list's empty space lands |
+
+### `sortable-lists--item`
+
+#### Values
+
+| Value | Type | Default | Meaning |
+| --- | --- | --- | --- |
+| `id` | string | | the record id; expands `{id}` in the move URL and is sent as a neighbor's `prev_id` |
+| `type` | string | | matched against lists' `acceptedType`; keys `moveUrlTemplates` ([typing data](https://atlassian.design/components/pragmatic-drag-and-drop/core-package/recipes/typing-data)) |
+| `label` | string | | accessible name used in announcements and in the external drag data |
+| `confined` | boolean | `false` | reorders within its own list only; foreign lists refuse it |
+| `hideUnavailable` | boolean | `true` | hide inapplicable move actions; `false` disables them instead |
+| `externalUrl` | URL | | when set, a drag also carries `text/uri-list`, `text/plain` and, with a label, `text/html` for targets outside the page ([data for external consumers](https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/element/about#data-for-external-consumers-getinitialdataforexternal), [external adapter](https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/external/about)) |
+
+#### Targets
+
+| Target | Cardinality | Meaning |
+| --- | --- | --- |
+| `handle` | one | the element that starts a drag; the whole row when absent ([drag handles](https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/element/about#drag-handles)) |
+| `preview` | one | the element that follows the pointer; the row when absent ([drag previews](https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/element/drag-previews)) |
+| `moveItem` | one per move action | a move action; carries the `direction` param |
+| `moveMenu` | one | the submenu or group wrapping the move actions, hidden or disabled when none applies |
+| `moveDivider` | one | the divider above the move actions, hidden together with them |
+
+#### Actions
+
+| Action | Params | Meaning |
+| --- | --- | --- |
+| `move` | `direction`: `top`, `up`, `down` or `bottom` | moves the row without a drag; bound as `click->sortable-lists--item#move` |
+
+### `sortable-lists--scrollable`
+
+#### Values
+
+| Value | Type | Default | Meaning |
+| --- | --- | --- | --- |
+| `allowedAxis` | `vertical`, `horizontal` or `all` | `vertical` | which edges trigger auto-scroll |
+| `maxScrollSpeed` | `standard` or `fast` | `standard` | |
+
+## The move request
+
+`PUT` to the expanded move URL with `responseKind: 'turbo-stream'` and three
+form fields:
+
+| Field | Meaning |
+| --- | --- |
+| `list_type` | the destination list's `type` |
+| `list_id` | the destination list's `id`; blank when it declares none, required as soon as two lists share a type |
+| `prev_id` | id of the row the moved row now follows; blank when it landed first |
+
+`prev_id` is always a record id, whatever the server orders by underneath. A
+drop that leaves the row where it started sends no request.
+
+## The server half
+
+Two ordering families back this request. A blank `prev_id` moves the
+record to the top in both. What happens to an anchor that is unknown,
+outside the destination's scope, or the moved record itself depends on the
+implementation:
+
+* `acts_as_list` models include `Lists::MoveAfterAnchor`, which resolves the
+ anchor within a scope you pass and reinserts relative to it. It refuses
+ such anchors, returning `false` without mutating anything.
+* Backlogs' own `move_after` removes the row from its list before looking
+ the anchor up, and an anchor it cannot find puts the row at the top
+ instead of refusing. The streamed list reconciles the client afterwards;
+ the Sibling lists tab describes when Backlogs reloads for that reason.
+* Models ordering by an array of keys insert after the anchor's key,
+ translating the incoming id to that key first.
+
+A move that changes list is a reparent plus an insert: one transaction,
+both lists locked, so a failed insert leaves the record in its original
+list. Answer refusals with `422` after streaming your own message; the client
+keeps that status silent apart from the rollback. Any other failure raises a
+generic toast.
+
+## Response policy
+
+The row moves in the DOM before the request resolves.
+
+* **Morph reconciliation (default).** The endpoint streams the re-rendered
+ list; the morph replaces the client's placement with the server's order.
+* **Event-only (`optimistic`).** The endpoint may acknowledge with an event
+ alone. The server decides per request whether the client's placement is
+ trustworthy; see the Sibling lists tab.
+
+A failed request rolls the rows back, unless a concurrent morph has already
+moved them.
+
+## Angular counterpart
+
+Angular surfaces use the `opSortableLists`, `opSortableListsList` and
+`opSortableListsItem` directives in
+`frontend/src/app/shared/directives/sortable-lists/`, backed by the engine
+in `frontend/src/common/drag-and-drop/sortable-lists-engine.ts`. Both
+suites build on Pragmatic drag and drop and follow the same styling
+conventions (`data-dragging`, `data-drop-position`, `data-drop-container`),
+but share no code.
+
+They differ on persistence, and this paragraph is only an overview; the
+event interfaces in `sortable-lists.directive.ts` are the contract. A drop
+emits an `opSortableListsDrop` event carrying the target and edge. The
+component persists the result itself, then calls the event's
+`complete(success)`; until it does, the root stays busy and refuses further
+drags, warning after ten seconds. A row leaving for another list also emits
+`opSortableListsRemoved` on its source list, settled with `finalize()`.
+There is no `prev_id` request. The draggable autocompleter uses these
+directives; new server-rendered surfaces use the Stimulus suite.
+
+## Used in
+
+* Text transform actions (`Admin::TextTransformActions`): one list.
+* Backlogs (`modules/backlogs`): sibling lists, scrollable columns,
+ optimistic placement, confined rows, external drag data.
+* Project Attributes (`Settings::ProjectCustomFieldSections`): nested lists
+ with per-type move endpoints.
+
+## Migrating off `generic-drag-and-drop`
+
+| `generic-drag-and-drop` | `sortable-lists` |
+| --- | --- |
+| `generic_drag_and_drop_target: "container"` | a `sortable-lists--list` controller on the list |
+| `generic_drag_and_drop_target: "scrollContainer"` | a `sortable-lists--scrollable` controller |
+| `drop-url` on the row | `move-url-template` on the root |
+| `draggable-type` / `target-allowed-drag-type` | the row's `type` and the list's `accepted-type` |
+| index-based position params | `list_type` + `list_id` + `prev_id` |
+| no keyboard path | `moveItem` menu actions |
+
+The endpoint changes shape too: Dragula posted an index, so a server that
+still computes positions from one needs porting to anchor semantics before
+its list can move over.
diff --git a/lookbook/docs/patterns/accessibility/18-aria-live.md.erb b/lookbook/docs/20-patterns/accessibility/18-aria-live.md.erb
similarity index 100%
rename from lookbook/docs/patterns/accessibility/18-aria-live.md.erb
rename to lookbook/docs/20-patterns/accessibility/18-aria-live.md.erb
diff --git a/lookbook/docs/patterns/accessibility/19-blank-target-links.md.erb b/lookbook/docs/20-patterns/accessibility/19-blank-target-links.md.erb
similarity index 100%
rename from lookbook/docs/patterns/accessibility/19-blank-target-links.md.erb
rename to lookbook/docs/20-patterns/accessibility/19-blank-target-links.md.erb
diff --git a/lookbook/docs/styles/02-icons.md.erb b/lookbook/docs/40-styles/02-icons.md.erb
similarity index 100%
rename from lookbook/docs/styles/02-icons.md.erb
rename to lookbook/docs/40-styles/02-icons.md.erb
diff --git a/lookbook/docs/styles/03-colors.md.erb b/lookbook/docs/40-styles/03-colors.md.erb
similarity index 100%
rename from lookbook/docs/styles/03-colors.md.erb
rename to lookbook/docs/40-styles/03-colors.md.erb
diff --git a/lookbook/docs/styles/10-box-list-item-states.md.erb b/lookbook/docs/40-styles/10-box-list-item-states.md.erb
similarity index 100%
rename from lookbook/docs/styles/10-box-list-item-states.md.erb
rename to lookbook/docs/40-styles/10-box-list-item-states.md.erb
diff --git a/lookbook/previews/patterns/sortable_lists_preview.rb b/lookbook/previews/patterns/sortable_lists_preview.rb
new file mode 100644
index 000000000000..76a29829a361
--- /dev/null
+++ b/lookbook/previews/patterns/sortable_lists_preview.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module Patterns
+ # @hidden
+ class SortableListsPreview < ViewComponent::Preview
+ # Rows drag and the move menu works, but the move URL points at a path
+ # that does not exist, so every drop ends in the generic error toast and
+ # the rows roll back. Persistence needs a real endpoint.
+ # @display min_height 320px
+ def single_list
+ render_with_template
+ end
+
+ # Two lists accepting the same item type under one root. Rows drag
+ # between them and the move menu reorders within a list; the request
+ # fails for the same reason as in the single list. No action changes
+ # list: that keyboard path is the consumer's, see the Sibling lists tab.
+ # @display min_height 320px
+ def sibling_lists
+ render_with_template
+ end
+ end
+end
diff --git a/lookbook/previews/patterns/sortable_lists_preview/sibling_lists.html.erb b/lookbook/previews/patterns/sortable_lists_preview/sibling_lists.html.erb
new file mode 100644
index 000000000000..f095fa81b17a
--- /dev/null
+++ b/lookbook/previews/patterns/sortable_lists_preview/sibling_lists.html.erb
@@ -0,0 +1,70 @@
+
+ <%= render(Primer::Box.new(display: :flex, style: "gap: 16px")) do %>
+ <% { "todo" => ["Write the spec", "Review the design"], "done" => ["Ship it"] }.each do |list_id, titles| %>
+ <%= render(Primer::Box.new(flex: 1)) do %>
+ <%=
+ render(
+ OpenProject::Common::BorderBoxListComponent.new(
+ container: "sortable-lists-preview-#{list_id}",
+ data: {
+ controller: "sortable-lists--list",
+ sortable_lists__list_type_value: "task",
+ sortable_lists__list_accepted_type_value: "task",
+ sortable_lists__list_id_value: list_id,
+ sortable_lists__list_name_value: list_id.capitalize
+ }
+ )
+ ) do |list|
+ list.with_header(title: list_id.capitalize)
+ move_items = { "Move to top" => "top", "Move up" => "up", "Move down" => "down", "Move to bottom" => "bottom" }
+
+ titles.each do |title|
+ list.with_item(
+ data: {
+ controller: "sortable-lists--item",
+ sortable_lists__item_id_value: title.parameterize,
+ sortable_lists__item_type_value: "task",
+ sortable_lists__item_label_value: title
+ }
+ ) do
+ render(Primer::Box.new(display: :flex, align_items: :center)) do
+ concat(
+ render(
+ Primer::OpenProject::DragHandle.new(
+ classes: "handle",
+ mr: 2,
+ data: { sortable_lists__item_target: "handle" }
+ )
+ )
+ )
+ concat(render(Primer::Beta::Text.new(flex: 1)) { title })
+ concat(
+ render(Primer::Alpha::ActionMenu.new(anchor_align: :end)) do |menu|
+ menu.with_show_button(icon: "kebab-horizontal", "aria-label": "Actions for #{title}", scheme: :invisible)
+ menu.with_divider(data: { sortable_lists__item_target: "moveDivider" })
+ move_items.each do |label, direction|
+ menu.with_item(
+ label:,
+ tag: :button,
+ data: {
+ sortable_lists__item_target: "moveItem",
+ sortable_lists__item_direction_param: direction,
+ action: "click->sortable-lists--item#move"
+ }
+ )
+ end
+ end
+ )
+ end
+ end
+ end
+ end
+ %>
+ <% end %>
+ <% end %>
+ <% end %>
+
diff --git a/lookbook/previews/patterns/sortable_lists_preview/single_list.html.erb b/lookbook/previews/patterns/sortable_lists_preview/single_list.html.erb
new file mode 100644
index 000000000000..8cb8bd888168
--- /dev/null
+++ b/lookbook/previews/patterns/sortable_lists_preview/single_list.html.erb
@@ -0,0 +1,63 @@
+
+ <%=
+ render(
+ OpenProject::Common::BorderBoxListComponent.new(
+ container: "sortable-lists-preview",
+ data: {
+ controller: "sortable-lists--list",
+ sortable_lists__list_type_value: "task",
+ sortable_lists__list_accepted_type_value: "task",
+ sortable_lists__list_name_value: "Tasks"
+ }
+ )
+ ) do |list|
+ list.with_header(title: "Tasks")
+ move_items = { "Move to top" => "top", "Move up" => "up", "Move down" => "down", "Move to bottom" => "bottom" }
+
+ ["Write the spec", "Review the design", "Ship it"].each_with_index do |title, index|
+ list.with_item(
+ data: {
+ controller: "sortable-lists--item",
+ sortable_lists__item_id_value: index + 1,
+ sortable_lists__item_type_value: "task",
+ sortable_lists__item_label_value: title
+ }
+ ) do
+ render(Primer::Box.new(display: :flex, align_items: :center)) do
+ concat(
+ render(
+ Primer::OpenProject::DragHandle.new(
+ classes: "handle",
+ mr: 2,
+ data: { sortable_lists__item_target: "handle" }
+ )
+ )
+ )
+ concat(render(Primer::Beta::Text.new(flex: 1)) { title })
+ concat(
+ render(Primer::Alpha::ActionMenu.new(anchor_align: :end)) do |menu|
+ menu.with_show_button(icon: "kebab-horizontal", "aria-label": "Actions for #{title}", scheme: :invisible)
+ menu.with_divider(data: { sortable_lists__item_target: "moveDivider" })
+ move_items.each do |label, direction|
+ menu.with_item(
+ label:,
+ tag: :button,
+ data: {
+ sortable_lists__item_target: "moveItem",
+ sortable_lists__item_direction_param: direction,
+ action: "click->sortable-lists--item#move"
+ }
+ )
+ end
+ end
+ )
+ end
+ end
+ end
+ end
+ %>
+