fabrica

hanna/fabrica

feat(web): use the chip dropdown for editing issue/PR labels

dbb14b3 · hanna committed on 2026-07-26

The existing-issue/PR label editor was a flat checkbox list; it now uses
the same `<details>` dropdown as the new-issue picker, whose toggle shows
the applied labels as chips (JS replaces "Select labels"). Checking
labels and pressing Apply POSTs the set; the button works without JS.
Writers see the dropdown in place of the separate label-set display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
3 files changed · +41 −23UnifiedSplit
assets/base.css +12 −8
@@ -706,15 +706,19 @@ body.drawer-open .drawer-backdrop {
706 flex-wrap: wrap;706 flex-wrap: wrap;
707 gap: 0.35rem;707 gap: 0.35rem;
708}708}
709.label-picker {709/* Existing-issue label editor: the dropdown fills the sidebar width, its toggle
710 flex-direction: column;710 shows the applied labels as chips, and Apply sits at the foot of the popover
711 align-items: stretch;711 (the column popover stretches it full width). */
712.side-form .label-dropdown {
713 flex: 1;
714 margin-bottom: 0;
712}715}
713.label-picker .checkbox {716.side-form .label-dropdown > summary {
714 display: flex;717 width: 100%;
715 align-items: center;718 justify-content: space-between;
716 gap: 0.35rem;719}
717 margin: 0.15rem 0;720.label-apply {
721 margin-top: 0.5rem;
718}722}
719.label-swatch {723.label-swatch {
720 width: 0.8rem;724 width: 0.8rem;
crates/web/src/issues.rs +25 −13
@@ -285,10 +285,12 @@ pub(crate) async fn view(
285 }285 }
286 section {286 section {
287 h3 { "Labels" }287 h3 { "Labels" }
288 @if issue_labels.is_empty() { p class="muted" { "None" } }
289 @else { div class="label-set" { @for l in &issue_labels { (label_chip(l)) } } }
290 @if can_write && !repo_labels.is_empty() {288 @if can_write && !repo_labels.is_empty() {
291 (label_form(&issue, &repo_labels, &issue_labels, &csrf))289 (label_form(&issue, &repo_labels, &issue_labels, &csrf))
290 } @else if issue_labels.is_empty() {
291 p class="muted" { "None" }
292 } @else {
293 div class="label-set" { @for l in &issue_labels { (label_chip(l)) } }
292 }294 }
293 }295 }
294 (deps_section(&issue, &ctx, &deps, &dependents, &csrf, can_write))296 (deps_section(&issue, &ctx, &deps, &dependents, &csrf, can_write))
@@ -478,7 +480,10 @@ pub(crate) async fn assignee_form(
478 }480 }
479}481}
480482
481/// The label-toggle form: checkboxes for each repo label.483/// The label editor for an existing issue/PR: a `<details>` dropdown whose toggle
484/// shows the applied labels as chips (JS replaces the "Select labels" text), the
485/// same control as the new-issue picker. Checking labels and pressing Apply POSTs
486/// the whole set; the button also works without JS.
482pub(crate) fn label_form(487pub(crate) fn label_form(
483 issue: &Issue,488 issue: &Issue,
484 repo_labels: &[Label],489 repo_labels: &[Label],
@@ -487,19 +492,26 @@ pub(crate) fn label_form(
487) -> Markup {492) -> Markup {
488 let is_on = |id: &str| applied.iter().any(|l| l.id == id);493 let is_on = |id: &str| applied.iter().any(|l| l.id == id);
489 html! {494 html! {
490 // Each checkbox auto-submits the whole set (the handler replaces the495 form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form" {
491 // applied labels); a noscript Apply button keeps it working without JS.
492 form method="post" action=(format!("/issue/{}/labels", issue.id)) class="side-form label-picker" {
493 input type="hidden" name="_csrf" value=(csrf);496 input type="hidden" name="_csrf" value=(csrf);
494 @for l in repo_labels {497 details class="menu label-dropdown" data-menu data-label-dropdown {
495 label class="checkbox" {498 summary class="btn" {
496 input type="checkbox" name="label" value=(l.id) checked[is_on(&l.id)]499 span class="label-dropdown-label" data-label-summary { "Select labels" }
497 data-autosubmit;500 (icon(Icon::ChevronDown))
498 " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {}501 }
499 " " (l.name)502 div class="menu-popover label-menu" {
503 @for l in repo_labels {
504 label class="checkbox" {
505 input type="checkbox" name="label" value=(l.id) checked[is_on(&l.id)]
506 data-label-name=(l.name)
507 data-label-color=(css_color(&l.color));
508 " " span class="label-swatch" style=(format!("--label-color: {}", css_color(&l.color))) {}
509 " " (l.name)
510 }
511 }
512 button class="btn btn-primary inline-btn label-apply" type="submit" { "Apply" }
500 }513 }
501 }514 }
502 noscript { button class="btn inline-btn" type="submit" { "Apply labels" } }
503 }515 }
504 }516 }
505}517}
crates/web/src/pulls.rs +4 −2
@@ -183,10 +183,12 @@ pub(crate) async fn view(
183 }183 }
184 section {184 section {
185 h3 { "Labels" }185 h3 { "Labels" }
186 @if issue_labels.is_empty() { p class="muted" { "None" } }
187 @else { div class="label-set" { @for l in &issue_labels { (label_chip(l)) } } }
188 @if can_write && !repo_labels.is_empty() {186 @if can_write && !repo_labels.is_empty() {
189 (label_form(&issue, &repo_labels, &issue_labels, &csrf))187 (label_form(&issue, &repo_labels, &issue_labels, &csrf))
188 } @else if issue_labels.is_empty() {
189 p class="muted" { "None" }
190 } @else {
191 div class="label-set" { @for l in &issue_labels { (label_chip(l)) } }
190 }192 }
191 }193 }
192 (deps_section(&issue, &ctx, &deps, &dependents, &csrf, can_write))194 (deps_section(&issue, &ctx, &deps, &dependents, &csrf, can_write))