Repository navigation
[DREAM-774] Document sortable lists in Lookbook #25137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
211f215
0aa026b
74f804b
b6545fe
5513b07
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| Drag and drop lets users reorder rows and move them between lists. This | ||
| page brings together the design guidance and the implementation of the | ||
| `sortable-lists` controller suite. | ||
|
|
||
| Start with **Design guidance** for interaction decisions. To implement drag | ||
| and drop, choose **Implementation overview** in the [tabs below](#page-tabbed-sections), | ||
| then **One list**, **Sibling lists** or **Nested lists** for your scenario. | ||
| **API reference** covers the attributes, request and response contract, and | ||
| migration from `generic-drag-and-drop`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| --- | ||
| label: Design guidance | ||
| --- | ||
|
|
||
| Drag and drop lets a user change the order of rows, or move a row into a | ||
| neighboring list, by picking it up and releasing it where it belongs. In | ||
| OpenProject it is always paired with a move menu, so the same change is | ||
| reachable from the keyboard and with a screen reader. For implementation | ||
| details, choose **Implementation overview** in the [tabs above](#page-tabbed-sections), | ||
| then the scenario that matches your list. | ||
|
|
||
| ## Anatomy | ||
|
|
||
| <img src="<%= image_path("lookbook/drag_and_drop_anatomy.png") %>" alt="A list of four rows with drag handles; the second row is dimmed while its preview floats beside the list and a line between the third and fourth rows marks where it will land" width="636" style="max-width: 100%"> | ||
|
|
||
| 1. **Drag handle**: the grip at the start of a list row. A drag starts here, | ||
| nowhere else on the row. A card has no grip: the whole card is its handle. | ||
| 2. **Drag source**: the row being moved. It stays in place, dimmed, until the | ||
| drop. | ||
| 3. **Drag preview**: the copy of the row that follows the pointer. | ||
| 4. **Drop indicator**: the line marking where the row will land. It marks a | ||
| spot without reserving space. | ||
| 5. **Drop container**: the outline on the list that will receive the row. | ||
|
|
||
| ## When to use it | ||
|
|
||
| Use drag and drop when the order of a list is itself the setting: the | ||
| position of an enumeration value, the sequence of sections on a form, the | ||
| position of work packages in a sprint. Do not add it to lists whose order | ||
| is derived (sorted by name or date) or where a single row moves rarely; a | ||
| move menu alone serves those. | ||
|
|
||
| ## Where it is used today | ||
|
|
||
| * Admin lists with a persisted order: enumerations, text transform actions, | ||
| project phase definitions, export templates, work package types. | ||
| * Project Attributes settings: sections reorder, and custom fields reorder | ||
| within a section or move to another. | ||
| * Backlogs: work packages reorder within the inbox and sprints and move | ||
| between them. | ||
|
|
||
| ## What users can expect | ||
|
|
||
| * **A visible drag handle** at the start of a list row. The row is picked up | ||
| from the handle, not from anywhere on it, so links and buttons inside the | ||
| row keep working. A card (Backlogs) shows no grip and is picked up from | ||
| anywhere on it except its links and buttons. | ||
| * **A drop indicator** that marks where the row will land, and a highlight | ||
| on the list that will receive it. | ||
| * **A move menu** in the row's action menu ("Move to top", "Move up", "Move | ||
| down", "Move to bottom") offering only the directions that apply. | ||
| * **An announcement** for screen-reader users when a row lands in another | ||
| list, naming that list. | ||
| * **Rows that cannot move** still show a position and can be dropped next | ||
| to; they simply refuse to be picked up, or refuse to leave their list. | ||
| * **Selecting several rows and moving them together** is being added for | ||
| Backlogs ([AGILE-278](https://community.openproject.org/wp/AGILE-278)). | ||
|
|
||
| ## Vocabulary | ||
|
|
||
| Design and review discussions use one set of terms: *drag source* (the | ||
| original row), *drag preview* (what follows the pointer), *drop indicator* | ||
| (the line marking the landing spot), *drop container* (the highlighted | ||
| receiving list), *reorder* (within a list) and *move* (between lists). | ||
| Avoid "mirror", "placeholder" and "container" for these; they meant other | ||
| things in the previous implementation. | ||
|
|
||
| ## Further reading | ||
|
|
||
| The implementation is built on Atlassian's Pragmatic drag and drop, whose | ||
| design guidance applies here too: | ||
|
|
||
| * [Web platform design constraints](https://atlassian.design/components/pragmatic-drag-and-drop/web-platform-design-constraints) | ||
| * [Accessibility guidelines](https://atlassian.design/components/pragmatic-drag-and-drop/accessibility-guidelines) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| --- | ||
| label: Implementation overview | ||
| --- | ||
|
|
||
| Server-rendered lists become sortable through the `sortable-lists` Stimulus | ||
| controller suite. A drag or a move menu action sends one request describing | ||
| where the row landed; the server persists the order and streams the list | ||
| back. The same suite drives a single admin list, the Backlogs board and the | ||
| nested Project Attributes settings. | ||
|
|
||
| This page documents the markup and HTTP contract for wiring the controllers | ||
| by hand. The **Design guidance** tab covers when drag and drop is appropriate | ||
| and what users expect from it. | ||
|
|
||
| ## Before you wire anything | ||
|
|
||
| **Prefer the component API where it exists.** `BorderBoxListComponent` is | ||
| gaining `sortable_list:` / `sortable_item:` options that emit this contract | ||
| for you ([DREAM-805](https://community.openproject.org/wp/DREAM-805)). Once | ||
| those land they cover a single list and sibling lists of rows the server can | ||
| reorder. Wire by hand only for what they do not reach: several item types | ||
| under one root, per-type move endpoints, non-`BorderBoxList` markup such as | ||
| table rows or cards, or optimistic placement. | ||
|
|
||
| **Do not use `generic-drag-and-drop`.** The Dragula-based controller is | ||
| deprecated and closed to new consumers. The API reference tab has a migration | ||
| table for its remaining call sites. | ||
|
|
||
| ## Anatomy | ||
|
|
||
| Four controllers divide the work and find each other through Stimulus | ||
| outlets, never by walking the DOM: | ||
|
|
||
| | Controller | Placed on | Owns | | ||
| | --- | --- | --- | | ||
| | `sortable-lists` | one wrapper per page region | the move URL, the request, rollback on failure, the outlet selectors for the three children | | ||
| | `sortable-lists--list` | each list | the list's type, its id, which item type it accepts, where a drop on empty space lands | | ||
| | `sortable-lists--item` | each row that can move | the row's id, type and accessible label; the drag handle and preview; the move menu actions | | ||
| | `sortable-lists--scrollable` | whatever element scrolls | auto-scrolling while a drag hovers its edge | | ||
|
|
||
| ### A single list | ||
|
|
||
| <img src="<%= image_path("lookbook/sortable_lists_a.png") %>" alt="One root, one list, three rows; the root reaches the list and each row through outlets" width="460" style="max-width: 100%"> | ||
|
myabc marked this conversation as resolved.
|
||
|
|
||
| The minimum topology. Three controllers; the root reaches the other two | ||
| through outlets, never by traversing the DOM. | ||
|
|
||
| ### Sibling lists in scrolling columns | ||
|
|
||
| <img src="<%= image_path("lookbook/sortable_lists_b.png") %>" alt="Two scroll columns under one root, each holding lists of the same type; the scrollable sits between root and lists" width="636" style="max-width: 100%"> | ||
|
|
||
| Two scrolling columns, each holding several lists of one type. The | ||
| scrollables sit inside the root and are reached by outlet like every other | ||
| child; the root still addresses every item directly, however deep it sits. | ||
|
|
||
| ### How the root reaches its children | ||
|
|
||
| <img src="<%= image_path("lookbook/sortable_lists_c.png") %>" alt="The root's three outlet selectors resolving to the list, item and scrollable controllers" width="760" style="max-width: 100%"> | ||
|
|
||
| Nesting is DOM structure; this is how the controllers address each other. | ||
| The root declares one outlet per child type, so it holds a live collection | ||
| of each with no `closest()` and no `querySelector` walking. The selectors | ||
| are ordinary descendant selectors scoped to the root's own wrapper id, so | ||
| two roots on one page cannot capture each other's children: | ||
|
|
||
| ```html | ||
| <div id="tasks" | ||
| data-controller="sortable-lists" | ||
| data-sortable-lists-move-url-template-value="<%%= move_url_template %>" | ||
| data-sortable-lists-sortable-lists--list-outlet="#tasks [data-controller~='sortable-lists--list']" | ||
| data-sortable-lists-sortable-lists--item-outlet="#tasks [data-controller~='sortable-lists--item']"> | ||
| ``` | ||
|
|
||
| Because the selectors match at any depth, one root holds every list and | ||
| every item beneath it in one flat collection. A row's owning list is the | ||
| innermost list element that contains it. That single rule is what lets one | ||
| root serve nested lists as well as flat ones. | ||
|
|
||
| ### Vocabulary | ||
|
|
||
| * **Sortable list, sortable item, sortable item type**: a list whose order the | ||
| server persists, a row that takes part in that order, and the category a | ||
| list accepts. A row is an item once it carries | ||
| `data-sortable-lists--item-id-value`; that is what counts it and anchors | ||
| its neighbors' drops. The `sortable-lists--item` controller on it is what | ||
| makes it draggable and gives it a move menu, and `confined` keeps a | ||
| draggable item in its own list. | ||
| * **Drag source**: the original row while it is being dragged. **Drag | ||
| preview**: the copy that follows the pointer. | ||
| * **Drop indicator**: the line marking where the row will land, drawn without | ||
| reserving space. **Drop container**: the list-level outline shown while a | ||
| compatible row hovers a list (`data-drop-container="active"`). | ||
| * **Reorder** happens within one list; a **move** changes list. In terms of | ||
| the backend request they are identical. | ||
|
Comment on lines
+81
to
+94
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want a diagram here too? - or does Behaviours already cover it? |
||
|
|
||
| ## Choosing a scenario | ||
|
|
||
| * **One list**: rows reorder within a single list, such as admin enumerations | ||
| or text transform actions. | ||
| * **Sibling lists**: several lists of the same kind, with rows moving between | ||
| them, as in Backlogs. | ||
| * **Nested lists**: rows that are themselves lists, such as Project Attributes | ||
| sections and their custom fields. | ||
| * **API reference**: every attribute, the backend request, the server half, the | ||
| Angular counterpart, migration. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's the first time we're using this, but I think it helps make the page more manageable where we have a bunch of different options. Here's the Lookbook docs - just a matter of naming the |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| --- | ||
| label: One list | ||
| --- | ||
|
|
||
| One list whose rows the server can reorder. `Admin::TextTransformActions` is | ||
| the smallest complete implementation in the codebase; the samples below | ||
| follow it. | ||
|
|
||
| <%= embed Patterns::SortableListsPreview, :single_list, panels: %i[source] %> | ||
|
|
||
| The preview's move URL points at a path that does not exist, so a drop moves | ||
| the row, the request fails, and the row rolls back with an error toast. That | ||
| is the whole failure path; persistence needs the endpoint described at the | ||
| end of this tab. | ||
|
|
||
| ## The root | ||
|
|
||
| One wrapper per page region. It carries the move URL template and the outlet | ||
| selectors, scoped to its own id: | ||
|
|
||
| ```html | ||
| <div id="text_transform_actions_list" | ||
| data-controller="sortable-lists" | ||
| data-sortable-lists-move-url-template-value="<%%= move_url_template %>" | ||
| data-sortable-lists-sortable-lists--list-outlet="#text_transform_actions_list [data-controller~='sortable-lists--list']" | ||
| data-sortable-lists-sortable-lists--item-outlet="#text_transform_actions_list [data-controller~='sortable-lists--item']"> | ||
| ``` | ||
|
|
||
| Build the template from the route helper through a sentinel, so an | ||
| installation under a relative URL root keeps working; `{id}` is expanded on | ||
| the client with the dragged row's id: | ||
|
|
||
| ```ruby | ||
| def move_url_template | ||
| id_placeholder = "__id__" | ||
| drop_admin_text_transform_action_path(id_placeholder).sub(id_placeholder, "{id}") | ||
| end | ||
| ``` | ||
|
|
||
| The expanded URL must stay same-origin and relative; only path, query and | ||
| fragment survive. | ||
|
|
||
| ## The list | ||
|
|
||
| The list element declares the type it persists and the type it accepts. | ||
| `type` doubles as the `list_type` the server receives, so a list without one | ||
| cannot accept drops and warns in the console. `name` is what screen readers | ||
| hear when a row arrives from another list; give it even for one list, it | ||
| costs nothing and the list may gain siblings. | ||
|
|
||
| ```html | ||
| <div data-controller="sortable-lists--list" | ||
| data-sortable-lists--list-type-value="text_transform_action" | ||
| data-sortable-lists--list-accepted-type-value="text_transform_action" | ||
| data-sortable-lists--list-name-value="<%%= t(".title") %>"> | ||
| <ul> | ||
| …rows… | ||
| </ul> | ||
| </div> | ||
| ``` | ||
|
|
||
| Rows may sit in a child `<ul>`, as `BorderBoxList` renders them, or directly | ||
| under the list element. Both resolve without configuration, so table rows | ||
| and cards need no special markup. | ||
|
|
||
| ## The row | ||
|
|
||
| Each row declares its identity and hosts the drag handle: | ||
|
|
||
| ```html | ||
| <li data-controller="sortable-lists--item" | ||
| data-sortable-lists--item-id-value="<%%= action.id %>" | ||
| data-sortable-lists--item-type-value="text_transform_action" | ||
| data-sortable-lists--item-label-value="<%%= action.label %>"> | ||
| <button type="button" class="handle" data-sortable-lists--item-target="handle" aria-hidden="true" tabindex="-1"> | ||
| <%%= primer_octicon(:grabber) %> | ||
| </button> | ||
| … | ||
| </li> | ||
| ``` | ||
|
|
||
| `Primer::OpenProject::DragHandle` renders that button for you; give it the | ||
| `handle` target. | ||
|
|
||
| Without a `handle` target the whole row is the handle, which fights with | ||
| links and buttons inside it. Add a `preview` target on an inner element when | ||
| the row's own box is not what should follow the pointer (a card inside a | ||
| padded `<li>`, for instance); the default preview is the row itself. | ||
|
|
||
| ## The move menu | ||
|
|
||
| A drag is a pointer gesture, so every sortable row also needs a keyboard- | ||
| and screen-reader-reachable path. Put move actions in the row's Primer | ||
| `ActionMenu`; the controller depends on that component. Each action is a | ||
| `moveItem` target with a `direction` param (`top`, `up`, `down`, `bottom`) | ||
| and the `move` action, passed to `with_item` so the attributes land on the | ||
| menu's `<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, | ||
| data: { | ||
| sortable_lists__item_target: "moveItem", | ||
| sortable_lists__item_direction_param: "up", | ||
| action: "click->sortable-lists--item#move" | ||
| } | ||
| ) %> | ||
| <%% end %> | ||
| ``` | ||
|
|
||
| `move` looks up the row's `<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. | ||
|
|
||
| Availability is recomputed every time the menu opens: the first row has no | ||
| "up", the last no "down". Unavailable actions are hidden; set | ||
| `data-sortable-lists--item-hide-unavailable-value="false"` on the row to | ||
| disable them instead. | ||
|
|
||
| ## The backend request | ||
|
|
||
| A drop, or a move action, sends `PUT` to the expanded move URL with | ||
| `responseKind: 'turbo-stream'` and three form fields: | ||
|
|
||
| | Field | Value for one list | | ||
| | --- | --- | | ||
| | `list_type` | the list's `type` | | ||
| | `list_id` | blank; the list declares no `id` | | ||
| | `prev_id` | id of the row the moved row now follows, or blank when it landed first | | ||
|
|
||
| `prev_id` is an anchor, not an index: a stale ordinal cannot silently move | ||
| the wrong row. A drop that leaves the row where it was sends nothing. | ||
|
|
||
| The row moves in the DOM before the request resolves. The endpoint answers | ||
| by streaming the re-rendered list, and the morph replaces the optimistic | ||
| placement with the server's order. A failure rolls the row back; a `422` | ||
| stays silent because the server has already streamed its own message, any | ||
| other status raises a generic toast. | ||
|
|
||
| For `acts_as_list` models the endpoint is a few lines with the | ||
| `Lists::MoveAfterAnchor` concern; the API reference tab describes the server | ||
| half, including the anchor rules and what to do when the model orders by | ||
| something other than a position column. |

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not a big fan of the naming, both "behaviours" and "sortable-lists" as the name of the page... Why is that separated from the drag-and-drop page you added? Nobody will understand that they have to look below behaviors/sortable-lists if they are looking for how drag&drop can be implemented..
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@HDinger I've tried consolidating everything on the Patterns → Drag and drop page. Let me know what you think?