diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/README.md b/frontend/src/stimulus/controllers/dynamic/sortable-lists/README.md
index d5170938528a..6f0ab29ea752 100644
--- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/README.md
+++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/README.md
@@ -1,16 +1,11 @@
-# Sortable lists selection
+# Sortable lists implementation
-## Consumer wiring
+Consumer wiring, gestures, and movement behavior are documented in Lookbook
+under **Patterns → Drag and drop**
+(`/lookbook/pages/patterns/drag_and_drop` in a running application):
-Batch selection is opt-in per sortable root through `selectionEnabled`. The root
-sets `announcementScope` for its translation vocabulary and
-`selectionDescriptionId` for one shared description element. Selected items
-reference that description through `aria-describedby` on their focus host.
-
-Items declare `mobility`: `fixed`, `confined`, or `free`. Mobility gates dragging,
-selection eligibility, and positional moves. A missing value defaults to `free`;
-an unrecognised value resolves to `fixed`. Structural rows such as “Show more”
-are not sortable items and do not participate in selection.
+- [Selection and batch movement](../../../../../../lookbook/docs/20-patterns/44-drag-and-drop[sibling-lists].md.erb)
+- [Controller API](../../../../../../lookbook/docs/20-patterns/46-drag-and-drop[api-reference].md.erb)
## Model and ownership
@@ -31,60 +26,18 @@ An item belongs to its nearest ancestor sortable root. Independently nested
roots are ownership boundaries. Item lookup, focus targets, and range resolution
must respect the owning item and list.
-## Gestures and range sessions
-
-Ctrl/Cmd-click toggles individual items and can build a sparse batch across
-lists. The toggled item becomes the anchor even when deselected. Plain click
-replaces the selection with the movable item while allowing its activation.
-
-Shift-click, Shift+Space, and Shift+Arrow resize a range within the anchor's
-list. Independent selections present when the range starts remain selected,
-including selections in other lists. Repeated Shift gestures extend, shrink,
-or reverse only the range's contribution. Without an anchor, or when Shift
-crosses into another list, selection restarts at the acted-on item. Ranges
-crossing unloaded or non-movable items are rejected without changing selection.
-
-Ctrl/Cmd+A replaces the batch with loaded movable items of the focused item's
-type in its list. A subsequent Shift gesture can narrow or reverse that
-selection. An individual toggle starts a fresh range baseline.
-
-Space toggles the focused item. Arrows move focus between items, including fixed
-items; Home/End target the first/last movable item. Focus movement remains
-available during a move, but selection mutations are blocked. Escape clears
-selection and the anchor at document level, while respecting fields and overlays
-that own the key.
-
Reconciliation prunes unavailable items and rebinds the anchor to its live list.
Losing the anchor or moving it to another list ends the range session; unchanged
reconciliation preserves it.
-## Batch movement
-
-Dragging a selected item moves the whole batch. The root freezes the batch in
-the preview callback (`freezeDragBatch`) and marks its rows at drag start
-(`markDragBatch`), so later selection changes do not change the submitted items.
-Dragging an unselected item selects it, collapsing any wider selection.
-
-A batch may drop only on a destination every member accepts. A `confined` member
-limits the whole batch to its own list, so members confined to different lists
-leave no common destination and the drag offers none. `fixed` items cannot join
-a batch, and the one-type-per-batch rule above still applies.
-
-A selection-enabled root with `collectionMoveUrl` submits ordered `ids[]` to the
-collection move action for one dragged item or many. The root's
-`moveAnnouncementScope` sets the translation vocabulary for move announcements,
-independently of the selection's `announcementScope`.
-
-## Presentation and feedback
+The root freezes the drag batch in `freezeDragBatch` during preview generation
+and marks its rows in `markDragBatch` at drag start. Both operate on the same
+snapshot, independently of later selection changes.
-`data-batch-selected` belongs on the sortable item element. In Backlogs this is
-the row, while `aria-current` belongs on the card inside it and represents the
-work package open in the details pane. Styles must account for those distinct
-elements and states. Updating the current work package does not change batch
-membership.
+[SortableActionMenu](action-menu.ts) projects action availability and scope onto
+Primer menus. The root resolves scope and permission policy; the projection
+owns visibility, naming, and focus recovery. Item target connections schedule
+one availability refresh after the menu fragment has connected.
-Selection gestures announce membership changes, including changes that retain
-the same count. Plain navigation clicks announce only when a wider batch
-collapses. Rejected ranges and range restarts have distinct translation keys.
-Tests use [keyed translation fixtures](testing/selection-translations.ts) to
-assert the selected key and plural form without duplicating production copy.
+Selection feedback tests use [keyed translation fixtures](testing/selection-translations.ts)
+to assert the selected key and plural form without duplicating production copy.
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
index b70bbf61b52f..6b96de26ac07 100644
--- 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
@@ -83,8 +83,9 @@ root serve nested lists as well as flat ones.
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.
+ handles dragging and its move menu. The `mobility` value determines
+ whether it is `free`, `confined` to permitted destinations, or `fixed`
+ in place. Fixed items still anchor their neighbors’ drops.
* **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
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
index bdc3080dd8fe..0cc21237572d 100644
--- 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
@@ -99,7 +99,6 @@ 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,
@@ -115,9 +114,9 @@ menu's ``, not on the button inside it:
`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.
+drags, its `move` action just does nothing. For menus combining singular
+and batch actions, use `invokerGroup`, `batchGroup`, and `groupDivider`
+targets as described in the API reference tab.
Availability is recomputed every time the menu opens: the first row has no
"up", the last no "down". Unavailable actions are hidden; set
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
index 2b282c2e4df1..b889e405931e 100644
--- 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
@@ -39,16 +39,16 @@ and lock both lists: a failed insert must leave the record where it started.
## 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.
+The move menu from the One list tab reorders within the current list. To
+change lists without dragging, add destination actions. Backlogs' card menu
+uses `moveToDestination` for a direct destination and `prepareDialog` to put
+ordered `ids[]` into a destination dialog's form. Both act on the selected
+batch when invoked from a selected card; an unselected movable card becomes
+the single-item scope.
+
+These actions submit to `collectionMoveUrl`. An omitted `prev_id` appends the
+batch; an explicitly blank `prev_id` inserts it at the top. The preview above
+has no destination action, so its cross-list move is drag-only.
## Drops on empty space
@@ -59,15 +59,16 @@ 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.
+Set `data-sortable-lists--item-mobility-value="confined"` on a row the server
+will reorder in place but refuses to relocate. It remains draggable and
+selectable. A batch can drop only where every member's destination policy
+allows it; a confined member therefore constrains the whole batch to its own
+list. Members confined to different lists leave no common destination.
-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.
+A `fixed` item cannot be selected or dragged but remains an addressable
+position for its neighbors' drops. Structural rows such as “Show more” have
+no sortable item identity and do not participate in selection. Missing
+`mobility` means `free`; an unrecognised value is treated as `fixed`.
## Auto-scrolling columns
@@ -118,8 +119,63 @@ 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.
+Selection is opt-in on the root with `selectionEnabled`. Set
+`collectionMoveUrl` to enable moving the selection, including a selection of
+one item. The root's `announcementScope` supplies selection translations;
+`moveAnnouncementScope` supplies movement translations. Set
+`selectionDescriptionId` to one shared description element: selected items
+reference it with `aria-describedby` on their `focus` target, or on the item
+itself when that target is absent. See the API reference tab for defaults.
+
+## Gestures and range sessions
+
+Ctrl/Cmd-click toggles individual items and can build a sparse batch across
+lists. The toggled item becomes the anchor even when deselected. Plain click
+replaces the selection with the movable item while allowing its activation.
+
+Shift-click, Shift+Space, and Shift+Arrow resize a range within the anchor's
+list. Independent selections present when the range starts remain selected,
+including selections in other lists. Repeated Shift gestures extend, shrink,
+or reverse only the range's contribution. Without an anchor, or when Shift
+crosses into another list, selection restarts at the acted-on item. Ranges
+crossing unloaded or non-movable items are rejected without changing selection.
+
+Ctrl/Cmd+A replaces the batch with loaded movable items of the focused item's
+type in its list. A subsequent Shift gesture can narrow or reverse that
+selection. An individual toggle starts a fresh range baseline.
+
+Space toggles the focused item. Arrows move focus between items, including fixed
+items; Home/End target the first/last movable item. Focus movement remains
+available during a move, but selection mutations are blocked. Escape clears
+selection and the anchor at document level, while respecting fields and overlays
+that own the key.
+
+## Moving the selection
+
+Dragging a selected item moves the whole batch in document order. Dragging an
+unselected movable item replaces the batch with that item. The batch is frozen
+before the drag preview is rendered, and a multi-item preview shows its count.
+Only destinations permitted for every member accept the drop.
+
+Position actions require a contiguous block in one list. Destination actions
+can move a sparse batch across lists. A menu opened from a selected item acts
+on the batch and presents its batch actions; singular actions remain tied to
+the invoking item. Unavailable actions are hidden by default, or disabled when
+`hideUnavailable` is false.
+
+`maxBatchSize` limits move operations, not selection. Backlogs provisionally sets it to 50
+and independently enforces that limit on the server. The server authorizes
+and moves the whole batch atomically. Failed drag or position requests roll
+back optimistic placement unless a newer server morph has already moved the
+rows; destination actions use the server's streamed result.
+
+## Selection, current item, and focus
+
+`data-batch-selected` marks the sortable row. Backlogs places `aria-current`
+on the inner card whose work package is open in the details pane. Opening
+details does not itself alter batch membership; focus is a third, independent
+state. See **Styles → Box list item states** for the visual treatment.
+
+Selection gestures announce membership changes even when the count stays the
+same. Plain navigation clicks announce only when they collapse a wider batch.
+Rejected ranges and range restarts have separate announcement keys.
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
index 20355dfd2415..b2f9f455d144 100644
--- 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
@@ -35,6 +35,12 @@ controller with a double underscore and the rest with single ones:
| `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 |
+| `selectionEnabled` | boolean | `false` | enables batch selection and its pointer and keyboard gestures |
+| `collectionMoveUrl` | string | | same-origin collection endpoint receiving ordered `ids[]` for one or many items |
+| `announcementScope` | string | `js.sortable_lists.selection` | translation scope for selection feedback |
+| `moveAnnouncementScope` | string | `js.sortable_lists.announcements` | translation scope for move feedback |
+| `selectionDescriptionId` | string | empty | shared description referenced by selected items' focus hosts through `aria-describedby` |
+| `maxBatchSize` | number | `0` | maximum items per move; zero means no client limit; selection itself is not capped |
#### Outlets
@@ -65,7 +71,8 @@ controller with a double underscore and the rest with single ones:
| `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 |
+| `mobility` | `free`, `confined`, `fixed` | `free` | controls selection and movement; unknown values are treated as `fixed`; see Sibling lists |
+| `menuLabelKey` | string | | translation key pluralized by action scope size; omit to preserve the server-rendered menu name |
| `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)) |
@@ -77,13 +84,20 @@ controller with a double underscore and the rest with single ones:
| `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 |
+| `focus` | one | focus host inside the row; the item itself when absent |
+| `destinationItem` | one per destination action | carries JSON destination candidates in `data-sortable-lists-destinations` |
+| `invokerGroup` | one | singular actions for the invoking item |
+| `batchGroup` | one | actions for the resolved scope, hidden when none is presented |
+| `groupDivider` | one | separates invoker and batch groups; visible only when both groups are presented |
#### Actions
| Action | Params | Meaning |
| --- | --- | --- |
-| `move` | `direction`: `top`, `up`, `down` or `bottom` | moves the row without a drag; bound as `click->sortable-lists--item#move` |
+| `move` | `direction`: `top`, `up`, `down` or `bottom` | reorders the resolved scope without a drag; a batch must be contiguous in one list |
+| `moveToDestination` | | submits the resolved scope to the destination item's single candidate |
+| `prepareDialog` | event detail: `form` | supplies ordered `ids[]` to the destination form; cancels if the scope is refused or the root is busy |
+| `focusItem` | | focuses the `focus` target, falling back to the item |
### `sortable-lists--scrollable`
@@ -108,6 +122,19 @@ form fields:
`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.
+### Collection moves
+
+A selection-enabled consumer supplies `collectionMoveUrl` instead of expanding
+one URL per item. The request adds ordered `ids[]` to the destination fields
+above, for a single item as well as a batch. In Backlogs, an omitted `prev_id`
+appends after the last non-batch item; an explicitly blank value inserts at the
+top. A nonblank value identifies the anchor before the block.
+
+Backlogs authorizes every member and applies the batch atomically, with a
+provisional server limit of 50 items. The root's `maxBatchSize` provides early feedback
+but does not replace server validation. This collection contract is separate
+from the single-record ordering implementations below.
+
## The server half
Two ordering families back this request. A blank `prev_id` moves the
diff --git a/lookbook/docs/40-styles/10-box-list-item-states.md.erb b/lookbook/docs/40-styles/10-box-list-item-states.md.erb
index 985bf8e2b6d7..71dd2b7ce533 100644
--- a/lookbook/docs/40-styles/10-box-list-item-states.md.erb
+++ b/lookbook/docs/40-styles/10-box-list-item-states.md.erb
@@ -1,4 +1,4 @@
-List items can have three visual states. The states are expressed via CSS classes on the row element (`` or equivalent) and are driven by CSS custom properties defined in `_variable_defaults.scss`.
+List items can have three visual states. The states are driven by CSS custom properties defined in `_variable_defaults.scss`. Each surface maps its selection and details state onto these styles. In Backlogs, `data-batch-selected` belongs on `.Box-row`, while `aria-current="true"` belongs on its inner `.Box-card`.
 %>)
@@ -16,7 +16,9 @@ Applied when an item is part of the active **(multi-)selection** - e.g. for bulk
Applied to the item that is currently **open in the split-screen**. A stronger blue border is added at the top and bottom (`--box-list-item-pressed-border-color`) to indicate the active detail context. The background colour remains unchanged.
-An item can carry both states simultaneously when it is selected *and* currently shown in the detail panel.
+An item can carry both states simultaneously when it is selected *and* currently shown in the detail panel. Keyboard focus is independent of both states. Opening another work package in the details pane does not change batch membership.
+
+The **Patterns → Drag and drop → Sibling lists** tab describes selection gestures and batch movement.
## CSS custom properties
diff --git a/lookbook/previews/patterns/sortable_lists_preview/sibling_lists.html.erb b/lookbook/previews/patterns/sortable_lists_preview/sibling_lists.html.erb
index f095fa81b17a..2416f0b5f15e 100644
--- a/lookbook/previews/patterns/sortable_lists_preview/sibling_lists.html.erb
+++ b/lookbook/previews/patterns/sortable_lists_preview/sibling_lists.html.erb
@@ -45,7 +45,6 @@
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:,
diff --git a/lookbook/previews/patterns/sortable_lists_preview/single_list.html.erb b/lookbook/previews/patterns/sortable_lists_preview/single_list.html.erb
index 8cb8bd888168..e8490b117186 100644
--- a/lookbook/previews/patterns/sortable_lists_preview/single_list.html.erb
+++ b/lookbook/previews/patterns/sortable_lists_preview/single_list.html.erb
@@ -41,7 +41,6 @@
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:,