Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 15 additions & 62 deletions frontend/src/stimulus/controllers/dynamic/sortable-lists/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions lookbook/docs/20-patterns/43-drag-and-drop[one-list].md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ menu's `<li>`, 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,
Expand All @@ -115,9 +114,9 @@ menu's `<li>`, not on the button inside it:
`move` looks up the row's `<action-menu>` element and asks it whether that
`<li>` is disabled or hidden before moving anything; the click reaches the
`<li>` 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
Expand Down
102 changes: 79 additions & 23 deletions lookbook/docs/20-patterns/44-drag-and-drop[sibling-lists].md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Comment on lines +166 to +167
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.
33 changes: 30 additions & 3 deletions lookbook/docs/20-patterns/46-drag-and-drop[api-reference].md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)) |

Expand All @@ -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`

Expand All @@ -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
Expand Down
Loading
Loading