Placing a widget without a mouse, in two dimensions #124

Closed
opened 2026-09-09 10:10:46 +00:00 by tiagoagueda · 1 comment
Owner

Observation

widgets organized by drag-drop

Second of three, and a prerequisite rather than a refinement: the grid cannot ship without
an answer here, because dragging reaches only some of the people who use this page.

What exists

The arrange page is a list of rows with four POSTs — Move up, Move down, Take off,
Add — and one more for Back to the standard arrangement. Every action is a form
submission, so it works with no JavaScript, from a keyboard, and under the back button.

DashboardView's own docstring says why it was built that way:

A list with buttons rather than dragging. The board can drag since #35, but that took real
work to make reachable without a mouse, and arranging is done once and then not again — a
form that posts is keyboard-workable, screen-reader-workable and script-free for nothing.

The board does drag, and static/js/app.js states the rule that governs every drag in
this application:

Drag and drop does not fire on touch screens and is not reachable from a keyboard, so it
is an addition to the control that works everywhere, never a replacement for it.

That rule is what makes this a dependency. Adding dragging to the dashboard is allowed by
it; replacing the buttons with dragging is not.

What this asks for

The control that works everywhere, for placing a widget in a two-dimensional grid — so that
dragging can be added on top of it rather than instead of it.

Worth being careful about

One dimension is not two. Move up and Move down are a complete vocabulary for a
list. A grid needs more: four directions, or a cell picker (a row and a column select per
widget), or a "move this one" mode where the next click chooses the destination. Naming
that mechanism is the whole of this issue, and the grid issue depends on the answer.

Scripts off has to keep working. The current page needs no JavaScript at all. Whatever
is chosen must still be a form that posts, or the dashboard becomes the first part of
Postulo that cannot be arranged without scripts.

Touch is not covered by HTML5 drag. The board's dragging does not fire on a phone,
which the comment above says outright. If dragging is meant to work on a touch screen it is
pointer events, not dragstart — and the phone is #73's subject, which this should not
quietly assume has been solved.

Focus and announcement. Moving a widget has to take focus with it and say what
happened, or somebody using a screen reader drags into silence. The board's status menu is
the precedent for a keyboard route that produces the same server call as the drag.

axe walks this page in both themes on every run. A drag handle that is a bare element
with a cursor is a finding, not a detail: it needs a name, a role and a visible focus state.

Reduced motion. Anything that animates a widget snapping into place respects
prefers-reduced-motion, which the stylesheet already brackets for its other animations.

Target size. SC 2.5.8 has been an active constraint on this project twice recently
(#115, and the telephone rows in #90). A drag handle and a remove control on the same row
are two targets that have to be 24 by 24 with room between them.

## Observation > widgets organized by drag-drop Second of three, and a prerequisite rather than a refinement: the grid cannot ship without an answer here, because dragging reaches only some of the people who use this page. ## What exists The arrange page is a list of rows with four POSTs — *Move up*, *Move down*, *Take off*, *Add* — and one more for *Back to the standard arrangement*. Every action is a form submission, so it works with no JavaScript, from a keyboard, and under the back button. `DashboardView`'s own docstring says why it was built that way: > A list with buttons rather than dragging. The board can drag since #35, but that took real > work to make reachable without a mouse, and arranging is done once and then not again — a > form that posts is keyboard-workable, screen-reader-workable and script-free for nothing. **The board does drag**, and `static/js/app.js` states the rule that governs every drag in this application: > Drag and drop does not fire on touch screens and is not reachable from a keyboard, so it > is an addition to the control that works everywhere, never a replacement for it. That rule is what makes this a dependency. Adding dragging to the dashboard is allowed by it; *replacing* the buttons with dragging is not. ## What this asks for The control that works everywhere, for placing a widget in a two-dimensional grid — so that dragging can be added on top of it rather than instead of it. ## Worth being careful about **One dimension is not two.** *Move up* and *Move down* are a complete vocabulary for a list. A grid needs more: four directions, or a cell picker (a row and a column select per widget), or a "move this one" mode where the next click chooses the destination. Naming that mechanism is the whole of this issue, and the grid issue depends on the answer. **Scripts off has to keep working.** The current page needs no JavaScript at all. Whatever is chosen must still be a form that posts, or the dashboard becomes the first part of Postulo that cannot be arranged without scripts. **Touch is not covered by HTML5 drag.** The board's dragging does not fire on a phone, which the comment above says outright. If dragging is meant to work on a touch screen it is pointer events, not `dragstart` — and the phone is #73's subject, which this should not quietly assume has been solved. **Focus and announcement.** Moving a widget has to take focus with it and say what happened, or somebody using a screen reader drags into silence. The board's status menu is the precedent for a keyboard route that produces the same server call as the drag. **axe walks this page in both themes on every run.** A drag handle that is a bare element with a cursor is a finding, not a detail: it needs a name, a role and a visible focus state. **Reduced motion.** Anything that animates a widget snapping into place respects `prefers-reduced-motion`, which the stylesheet already brackets for its other animations. **Target size.** SC 2.5.8 has been an active constraint on this project twice recently (#115, and the telephone rows in #90). A drag handle and a remove control on the same row are two targets that have to be 24 by 24 with room between them.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:10:46 +00:00
Author
Owner

Done in 23ac44d7.

Naming the mechanism, which was the whole of this issue

Of the three you listed, the answer is four directions — and the reason is that the dashboard is a flow, not a matrix. Widgets have widths and fill rows in order, so there is no cell to name:

  • a row-and-column picker would ask somebody to think in coordinates that do not exist;
  • a "move this one, then choose a destination" mode needs two interactions and state between them, which is exactly what cannot be done with scripts off;
  • four directions is the smallest extension to a vocabulary that already works.

So:

Direction What it does
left / right one place in the order — what up and down used to do
up / down a whole row, past everything sharing the row above or below

The old up and down were renamed to left and right, because the words had to be freed for the direction a list could not express.

On a narrow screen there is one column and the two axes coincide. That is not a degradation — it is what up means when there is only one column, and the same POST does it.

rows_of() computes the rows from the widths against the same twelve columns the template draws with, rather than storing them, so it stays true the day somebody changes what a widget spans. Out of range is a no-op rather than a wrap: pressing up on the top row means to find out that it is the top row.

Everything you flagged

Scripts off keeps working. Every arrow is a form that posts, and a test asserts the arranging list carries no draggable and no hx-. Nothing was replaced by dragging; the control is what dragging will be added on top of.

Focus and announcement. The redirect carries #widget-<key> and each row is tabindex="-1", so the page comes back on the widget that moved rather than at the top. A message says which row and place it landed in — row and place rather than a direction, because that is what somebody actually wants to know, it is the same sentence for all four buttons, and two numbers is what a two-dimensional control owes. The messages partial already carries role="status".

axe walks this page in both themes. Every arrow is an icon-only button with an sr-only name — move X up a row, one place earlier, and so on. The browser suite passes, 54 tests, both themes.

Target size (SC 2.5.8). Each arrow is a fixed 32×32 button in a gap-1 cluster, with the remove control separated by a gap-2. No two targets share an edge.

Reduced motion. Nothing animates: a move is a page load, which is the same non-animation the rest of this page has always had.

Touch. Not assumed solved. This control works on a phone because it is a button, which is the point — HTML5 drag would not, as the comment in app.js says outright, and #73 is where the phone gets its own attention.

Two details worth knowing about

A button that cannot act is disabled rather than hidden, so the cluster keeps its shape and the arrow somebody reaches for is where it was last time. can_move() is what the template asks.

The two horizontal arrows joined the icon set, and assets/css/app.css already mirrors arrow-left and arrow-right under dir="rtl" — an arrow meaning one place earlier that points at the far margin in Arabic is worse than no arrow. tests/test_template_lint.py is what holds the icon list and the flip rule together, and it passes.

Also

  • tests/test_widget_placement.py, 16 tests. Suite 4388 passed, 29 skipped; browser suite 54 passed.
  • Five new strings, filled in all 39 European catalogues.

The grid now has its control. Whatever it stores, this is the vocabulary that has to keep reaching it.

Done in `23ac44d7`. ## Naming the mechanism, which was the whole of this issue Of the three you listed, the answer is **four directions** — and the reason is that the dashboard is a **flow, not a matrix**. Widgets have widths and fill rows in order, so there is no cell to name: - a **row-and-column picker** would ask somebody to think in coordinates that do not exist; - a **"move this one, then choose a destination" mode** needs two interactions and state between them, which is exactly what cannot be done with scripts off; - **four directions** is the smallest extension to a vocabulary that already works. So: | Direction | What it does | | --- | --- | | **left** / **right** | one place in the order — what *up* and *down* used to do | | **up** / **down** | a whole row, past everything sharing the row above or below | The old *up* and *down* were renamed to *left* and *right*, because the words had to be freed for the direction a list could not express. **On a narrow screen there is one column and the two axes coincide.** That is not a degradation — it is what *up* means when there is only one column, and the same POST does it. `rows_of()` computes the rows from the widths against the same twelve columns the template draws with, rather than storing them, so it stays true the day somebody changes what a widget spans. Out of range is a no-op rather than a wrap: pressing *up* on the top row means to find out that it is the top row. ## Everything you flagged **Scripts off keeps working.** Every arrow is a form that posts, and a test asserts the arranging list carries no `draggable` and no `hx-`. Nothing was replaced by dragging; the control is what dragging will be added *on top of*. **Focus and announcement.** The redirect carries `#widget-<key>` and each row is `tabindex="-1"`, so the page comes back on the widget that moved rather than at the top. A message says **which row and place** it landed in — row and place rather than a direction, because that is what somebody actually wants to know, it is the same sentence for all four buttons, and two numbers is what a two-dimensional control owes. The messages partial already carries `role="status"`. **axe walks this page in both themes.** Every arrow is an icon-only button with an `sr-only` name — *move X up a row*, *one place earlier*, and so on. The browser suite passes, 54 tests, both themes. **Target size (SC 2.5.8).** Each arrow is a fixed 32×32 button in a gap-1 cluster, with the remove control separated by a gap-2. No two targets share an edge. **Reduced motion.** Nothing animates: a move is a page load, which is the same non-animation the rest of this page has always had. **Touch.** Not assumed solved. This control works on a phone because it is a button, which is the point — HTML5 drag would not, as the comment in `app.js` says outright, and #73 is where the phone gets its own attention. ## Two details worth knowing about **A button that cannot act is disabled rather than hidden**, so the cluster keeps its shape and the arrow somebody reaches for is where it was last time. `can_move()` is what the template asks. **The two horizontal arrows joined the icon set**, and `assets/css/app.css` already mirrors `arrow-left` and `arrow-right` under `dir="rtl"` — an arrow meaning *one place earlier* that points at the far margin in Arabic is worse than no arrow. `tests/test_template_lint.py` is what holds the icon list and the flip rule together, and it passes. ## Also - `tests/test_widget_placement.py`, 16 tests. Suite 4388 passed, 29 skipped; browser suite 54 passed. - Five new strings, filled in all 39 European catalogues. The grid now has its control. Whatever it stores, this is the vocabulary that has to keep reaching it.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#124
No description provided.