A control for choosing several labels, without a mouse and without a script #139

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

Observation

the selection should be in the form of labels, not tick boxes

Prerequisite. There is no such control in Postulo, and the two places that want one are not
only the company form.

What exists

Industries are a CheckboxSelectMultiple over the person's own Industry rows, with a
free-text Other industries box beside it backed by a <datalist> of suggestions. Tags on
an application are a plain ModelMultipleChoiceField — Django's default SelectMultiple, a
scrolling box you ctrl-click.

Neither is a label. Nothing in static/js/app.js or the templates draws a chip, so this is a
new control rather than a restyling of an old one.

What this asks for

One control that shows the chosen values as removable labels, offers the rest, and lets
somebody add one that does not exist yet — used by industries and by tags, not written twice.

Worth being careful about

It has to work with no JavaScript at all, because everything on these forms does today.
The honest fallback is the control that exists — checkboxes, or a multiple select — with the
labels layered on top when a script is available. That is the same rule the board's dragging
follows: an addition to the control that works everywhere, never a replacement.

A chip's remove button is a target, and this project has been caught by SC 2.5.8 twice
already
— once at 19 pixels on the plugins page (#115), once on the telephone rows. A × in
the corner of a label is exactly the shape that fails: it needs 24 by 24 with room around it,
and a row of eight chips is eight adjacent targets.

Each remove button needs its own name. Eight buttons all called Remove is eight
identical announcements; it has to be Remove Software, which means a string with the label
inside it — and therefore a blocktranslate per chip rather than one shared word.

The keyboard vocabulary is the whole design. Backspace removing the last chip is the
convention and it is also a way to delete something by accident; arrow keys moving between
chips, Enter committing a typed value, Escape abandoning it. Whatever is chosen, focus has to
land somewhere sensible after a removal rather than at the top of the form.

A typed value that is new is the interesting case. Industry.named() already creates
missing industries by slug, so the server side exists; the control has to make "this one is
new" visible before saving, or people will create Fintech, FinTech and fintech and only
find out afterwards that the slug collapsed them.

Announce the change. Adding or removing a label with a script updates nothing a screen
reader notices unless the count or the change goes into a live region.

Both themes, and axe walks these pages already. A chip is a coloured pill; the contrast
of its text against its own ground is 4.5:1, and its border against the card is 3:1.

Right to left. The × sits at the end edge, not the right; the template lint already fails
a physical direction class.

## Observation > the selection should be in the form of labels, not tick boxes Prerequisite. There is no such control in Postulo, and the two places that want one are not only the company form. ## What exists Industries are a `CheckboxSelectMultiple` over the person's own `Industry` rows, with a free-text *Other industries* box beside it backed by a `<datalist>` of suggestions. Tags on an application are a plain `ModelMultipleChoiceField` — Django's default `SelectMultiple`, a scrolling box you ctrl-click. Neither is a label. Nothing in `static/js/app.js` or the templates draws a chip, so this is a new control rather than a restyling of an old one. ## What this asks for One control that shows the chosen values as removable labels, offers the rest, and lets somebody add one that does not exist yet — used by industries and by tags, not written twice. ## Worth being careful about **It has to work with no JavaScript at all**, because everything on these forms does today. The honest fallback is the control that exists — checkboxes, or a multiple select — with the labels layered on top when a script is available. That is the same rule the board's dragging follows: an addition to the control that works everywhere, never a replacement. **A chip's remove button is a target, and this project has been caught by SC 2.5.8 twice already** — once at 19 pixels on the plugins page (#115), once on the telephone rows. A × in the corner of a label is exactly the shape that fails: it needs 24 by 24 with room around it, and a row of eight chips is eight adjacent targets. **Each remove button needs its own name.** Eight buttons all called *Remove* is eight identical announcements; it has to be *Remove Software*, which means a string with the label inside it — and therefore a `blocktranslate` per chip rather than one shared word. **The keyboard vocabulary is the whole design.** Backspace removing the last chip is the convention and it is also a way to delete something by accident; arrow keys moving between chips, Enter committing a typed value, Escape abandoning it. Whatever is chosen, focus has to land somewhere sensible after a removal rather than at the top of the form. **A typed value that is new is the interesting case.** `Industry.named()` already creates missing industries by slug, so the server side exists; the control has to make "this one is new" visible before saving, or people will create *Fintech*, *FinTech* and *fintech* and only find out afterwards that the slug collapsed them. **Announce the change.** Adding or removing a label with a script updates nothing a screen reader notices unless the count or the change goes into a live region. **Both themes, and axe walks these pages already.** A chip is a coloured pill; the contrast of its text against its own ground is 4.5:1, and its border against the card is 3:1. **Right to left.** The × sits at the end edge, not the right; the template lint already fails a physical direction class.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:41:56 +00:00
Author
Owner

Done in 969c3034.

One control, two places

partials/label_attrs.html carries the wrapper — the attribute names and the words — and each form keeps the native control it already drew, marked data-labels-existing. That is what "not written twice" turned out to mean here: the two places do not draw their native controls the same way (industries are a hand-laid row of checkboxes, tags are a multiple select), so sharing the markup would have meant making one of them worse. Sharing the wrapper shares everything that has to agree.

Used by the company form, the application intake form and the application editing form.

Layered, never substituted

The checkboxes, the multiple select and the box for a name that does not exist yet are all still in the page and still submitting — hidden keeps an element in the form. With the script blocked, both forms are exactly what they were.

The end-to-end test that matters most says so directly: remove a chip, type a new name, save, and check what the database got. A chip is a picture of a checkbox, and a picture that disagrees with what posts is worse than no picture.

The keyboard vocabulary, decided

Key What it does
Enter commits what was typed — and does not submit the form
Escape abandons what was typed
← → walk between labels, mapped through the reading direction
Tab reaches every remove button, as it always would
Backspace nothing

Backspace is the convention and it is deliberately not followed. It is a way to delete something by pressing the key you press to correct a typo, in a control whose values are somebody's own vocabulary — that is a bad trade, and every chip has a remove button two keys away. There is a test that says so, with the reason in it.

Focus after a removal stays where it can be used: the chips redraw in place and the remove buttons keep their order.

The new-name case

Industry.named() already existed; Tag now has the same, matched by slug, so Remote, remote and REMOTE stay one tag. Tags gained the "add one that does not exist yet" half that industries always had — the application form takes new_tags, both save paths use it, and a tag made that way carries no colour, because colours are chosen on the tags page where there is room to see them beside each other.

A new name is visible as new before saving — outlined rather than filled, and labelled — new. That is the specific thing you warned about: otherwise people create Fintech, FinTech and fintech and only find out afterwards that the slug collapsed them.

Everything else you flagged

  • Target size. The remove button is 24 × 24 (h-6 w-6) in a gap-2 row. A test reads the stylesheet, because SC 2.5.8 has caught this project twice.
  • Each remove button has its own name. Remove Software, not Remove — the name is inside the sentence, which is why the strings are {label} templates in data attributes rather than one shared word.
  • Announcement. Adding or removing writes into a role="status" region: Software added, Research removed.
  • Both themes and axe. A chip's text sits on its own ground and its border reads against the card in both; the browser suite passes, 65 tests, both themes.
  • Right to left. Padding is ps-3 pe-0.5, never pl/pr, and a test reads the stylesheet for that; the arrow keys flip with direction.

Also

  • tests/test_labels.py (14) and tests/e2e/test_labels.py (11). Suite 4406 passed, 29 skipped; browser suite 65 passed.
  • Eight new strings, filled in all 39 European catalogues.
Done in `969c3034`. ## One control, two places `partials/label_attrs.html` carries the wrapper — the attribute names and the words — and each form keeps the native control it already drew, marked `data-labels-existing`. That is what "not written twice" turned out to mean here: the two places do *not* draw their native controls the same way (industries are a hand-laid row of checkboxes, tags are a multiple select), so sharing the markup would have meant making one of them worse. Sharing the wrapper shares everything that has to agree. Used by the company form, the application intake form and the application editing form. ## Layered, never substituted The checkboxes, the multiple select and the box for a name that does not exist yet are all still in the page and still submitting — `hidden` keeps an element in the form. With the script blocked, both forms are exactly what they were. The end-to-end test that matters most says so directly: remove a chip, type a new name, save, and check what the database got. **A chip is a picture of a checkbox, and a picture that disagrees with what posts is worse than no picture.** ## The keyboard vocabulary, decided | Key | What it does | | --- | --- | | **Enter** | commits what was typed — and does *not* submit the form | | **Escape** | abandons what was typed | | **← →** | walk between labels, mapped through the reading direction | | **Tab** | reaches every remove button, as it always would | | **Backspace** | **nothing** | Backspace is the convention and it is deliberately not followed. It is a way to delete something by pressing the key you press to correct a typo, in a control whose values are somebody's own vocabulary — that is a bad trade, and every chip has a remove button two keys away. There is a test that says so, with the reason in it. Focus after a removal stays where it can be used: the chips redraw in place and the remove buttons keep their order. ## The new-name case `Industry.named()` already existed; `Tag` now has the same, matched by slug, so *Remote*, *remote* and *REMOTE* stay one tag. Tags gained the "add one that does not exist yet" half that industries always had — the application form takes `new_tags`, both save paths use it, and a tag made that way carries **no colour**, because colours are chosen on the tags page where there is room to see them beside each other. **A new name is visible as new before saving** — outlined rather than filled, and labelled *— new*. That is the specific thing you warned about: otherwise people create *Fintech*, *FinTech* and *fintech* and only find out afterwards that the slug collapsed them. ## Everything else you flagged - **Target size.** The remove button is 24 × 24 (`h-6 w-6`) in a `gap-2` row. A test reads the stylesheet, because SC 2.5.8 has caught this project twice. - **Each remove button has its own name.** *Remove Software*, not *Remove* — the name is inside the sentence, which is why the strings are `{label}` templates in data attributes rather than one shared word. - **Announcement.** Adding or removing writes into a `role="status"` region: *Software added*, *Research removed*. - **Both themes and axe.** A chip's text sits on its own ground and its border reads against the card in both; the browser suite passes, 65 tests, both themes. - **Right to left.** Padding is `ps-3 pe-0.5`, never `pl`/`pr`, and a test reads the stylesheet for that; the arrow keys flip with `direction`. ## Also - `tests/test_labels.py` (14) and `tests/e2e/test_labels.py` (11). Suite 4406 passed, 29 skipped; browser suite 65 passed. - Eight new strings, filled in all 39 European catalogues.
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#139
No description provided.