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`. ![Box list item color states overview](<%= image_path("lookbook/box_list_color_states.png") %>) @@ -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:,