From 016f6a208c7e686af778bdd12d4092bdaf5baa0c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 15 Sep 2026 18:39:25 +0100 Subject: [PATCH] [AGILE-436] Document batch selection in Lookbook Documents batch gestures, mobility, menu scopes and collection moves. Updates the API reference, selection styles and preview attributes. Keeps implementation invariants in the README and links to the guide. Separates the documentation from the approved AGILE-392 scope. https://community.openproject.org/wp/AGILE-436 --- .../dynamic/sortable-lists/README.md | 77 +++---------- ...g-and-drop[implementation-overview].md.erb | 5 +- .../43-drag-and-drop[one-list].md.erb | 7 +- .../44-drag-and-drop[sibling-lists].md.erb | 102 ++++++++++++++---- .../46-drag-and-drop[api-reference].md.erb | 33 +++++- .../40-styles/10-box-list-item-states.md.erb | 6 +- .../sibling_lists.html.erb | 1 - .../single_list.html.erb | 1 - 8 files changed, 134 insertions(+), 98 deletions(-) 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:,