From 36a28cff10000dbb8a9bb9e7865c6e679b4d6bdb Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Fri, 6 Feb 2026 16:42:42 -0800 Subject: [PATCH] [WV2] Add progressive enhancement for outfit item list toggles Rather than just buttons, upgrade to radio buttons when we have JS. --- .../javascripts/wardrobe/zone-item-group.js | 68 +++++++++++++++++++ app/assets/stylesheets/wardrobe/show.css | 36 ++++++++++ app/views/wardrobe/items/_item_card.html.haml | 13 ++-- .../items/_item_card_content.html.haml | 7 ++ app/views/wardrobe/show.html.haml | 8 ++- docs/wardrobe-v2-migration.md | 15 ++-- 6 files changed, 130 insertions(+), 17 deletions(-) create mode 100644 app/assets/javascripts/wardrobe/zone-item-group.js create mode 100644 app/views/wardrobe/items/_item_card_content.html.haml diff --git a/app/assets/javascripts/wardrobe/zone-item-group.js b/app/assets/javascripts/wardrobe/zone-item-group.js new file mode 100644 index 00000000..2347fabb --- /dev/null +++ b/app/assets/javascripts/wardrobe/zone-item-group.js @@ -0,0 +1,68 @@ +/** + * ZoneItemGroup web component + * + * Progressive enhancement for zone-grouped item cards in the outfit view. + * Replaces baseline Show/Hide buttons with radio-button behavior: + * - Clicking a closeted item's label selects its radio and submits the Show form + * - Clicking a worn item's label submits the Hide form (un-wears it) + * - Arrow keys navigate between items via native radio behavior, triggering + * a `change` event that submits the newly-selected item's Show form + */ +class ZoneItemGroup extends HTMLElement { + connectedCallback() { + this.addEventListener("click", this.#handleClick); + this.addEventListener("keydown", this.#handleKeydown); + this.addEventListener("change", this.#handleChange); + } + + #handleClick = (e) => { + // Only handle clicks on labels (the item card click target) + const label = e.target.closest(".item-card-label"); + if (!label) return; + + const radio = label.querySelector("input[type=radio]"); + if (!radio) return; + + const card = label.closest(".item-card"); + if (!card) return; + + // If this item is worn (radio was already checked), un-wear it + if (card.dataset.isWorn != null) { + // Prevent the default label behavior (which would keep radio checked) + e.preventDefault(); + const hideButton = card.querySelector(".item-hide-button"); + if (hideButton) hideButton.closest("form").requestSubmit(); + } + // If closeted, let the default label click proceed—it checks the radio, + // which fires the `change` event handled below + }; + + #handleKeydown = (e) => { + // Spacebar on an already-checked radio: un-wear the item + if (e.key !== " ") return; + + const radio = e.target; + if (radio.type !== "radio" || !radio.checked) return; + + const card = radio.closest(".item-card"); + if (!card || card.dataset.isWorn == null) return; + + e.preventDefault(); + const hideButton = card.querySelector(".item-hide-button"); + if (hideButton) hideButton.closest("form").requestSubmit(); + }; + + #handleChange = (e) => { + const radio = e.target; + if (radio.type !== "radio" || !radio.checked) return; + + const card = radio.closest(".item-card"); + if (!card) return; + + // Submit the Show form to wear this item + const showButton = card.querySelector(".item-show-button"); + if (showButton) showButton.closest("form").requestSubmit(); + }; +} + +customElements.define("zone-item-group", ZoneItemGroup); diff --git a/app/assets/stylesheets/wardrobe/show.css b/app/assets/stylesheets/wardrobe/show.css index 4b828adb..53f529ea 100644 --- a/app/assets/stylesheets/wardrobe/show.css +++ b/app/assets/stylesheets/wardrobe/show.css @@ -228,6 +228,42 @@ select, opacity: 0.75; } +/* Visually hidden inputs (radio/checkbox) - accessible but not visible */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Item card label - click target wrapping thumbnail + info */ +.item-card-label { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + flex: 1; + min-width: 0; +} + +/* When zone-item-group is defined (JS loaded), hide Show/Hide buttons + and make the label the primary interaction. Keep Remove visible. */ +zone-item-group:defined .item-show-button, +zone-item-group:defined .item-hide-button { + display: none; +} + +/* Focus ring on item card when radio is focused (keyboard navigation) */ +zone-item-group:defined .item-card:has(input[type="radio"]:focus-visible) { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + /* Pagination links - treated as buttons for consistency */ .pagination { a, diff --git a/app/views/wardrobe/items/_item_card.html.haml b/app/views/wardrobe/items/_item_card.html.haml index 4cc53d1f..25706f27 100644 --- a/app/views/wardrobe/items/_item_card.html.haml +++ b/app/views/wardrobe/items/_item_card.html.haml @@ -1,13 +1,12 @@ - is_worn = @outfit.worn_items.include?(item) - is_closeted = @outfit.closeted_items.include?(item) %li.item-card{data: {is_worn: is_worn || nil, is_closeted: is_closeted || nil}} - .item-thumbnail - = image_tag item.thumbnail_url, alt: item.name, loading: "lazy" - .item-info - .item-name= item.name - .item-badges - = render "items/badges/kind", item: item - = render "items/badges/first_seen", item: item + - if defined?(zone_id) && zone_id + %label.item-card-label + %input.visually-hidden{type: "radio", name: "zone_#{zone_id}", checked: is_worn || nil, "aria-label": item.name} + = render "wardrobe/items/item_card_content", item: item + - else + = render "wardrobe/items/item_card_content", item: item - if is_worn = button_to @wardrobe_path, method: :get, class: "item-hide-button", title: "Hide #{item.name}", "aria-label": "Hide #{item.name}" do 👁️‍🗨️ diff --git a/app/views/wardrobe/items/_item_card_content.html.haml b/app/views/wardrobe/items/_item_card_content.html.haml new file mode 100644 index 00000000..55d5e577 --- /dev/null +++ b/app/views/wardrobe/items/_item_card_content.html.haml @@ -0,0 +1,7 @@ +.item-thumbnail + = image_tag item.thumbnail_url, alt: "", loading: "lazy" +.item-info + .item-name= item.name + .item-badges + = render "items/badges/kind", item: item + = render "items/badges/first_seen", item: item diff --git a/app/views/wardrobe/show.html.haml b/app/views/wardrobe/show.html.haml index 32a76ea2..e86d23ea 100644 --- a/app/views/wardrobe/show.html.haml +++ b/app/views/wardrobe/show.html.haml @@ -17,6 +17,7 @@ = javascript_include_tag "pose-picker", async: true = javascript_include_tag "tab-panel", async: true = javascript_include_tag "outfit-rename-field", async: true + = javascript_include_tag "wardrobe/zone-item-group", async: true = javascript_include_tag "wardrobe/show", async: true = csrf_meta_tags %meta{name: 'outfit-viewer-morph-mode', value: 'full-page'} @@ -74,6 +75,7 @@ - outfit_items_by_zone(@outfit).each do |zone_group| .zone-group %h3.zone-label= zone_group[:zone_label] - %ul.items-list - - zone_group[:items].each do |item| - = render "items/item_card", item: item + %zone-item-group + %ul.items-list + - zone_group[:items].each do |item| + = render "items/item_card", item: item, zone_id: zone_group[:zone_id] diff --git a/docs/wardrobe-v2-migration.md b/docs/wardrobe-v2-migration.md index 0ebcb419..6609a363 100644 --- a/docs/wardrobe-v2-migration.md +++ b/docs/wardrobe-v2-migration.md @@ -69,13 +69,14 @@ The goal is a basic usable wardrobe. Species/color/pose selection, item search, - Visual distinction: worn items have green emphasis, closeted items have dashed border and reduced opacity. - Closeted items appear in zone groups alongside worn items. - `closet[]` URL params, saving/loading, and search pagination all work. -- Progressive enhancement (pending): - - In the outfit view, items in the same zone group (mutually-incompatible) are a radio group. Whichever radio button - is checked is the worn item. The others are merely closeted. - - In the search view, each item has a worn checkbox (analogous to the worn radio button). - - In both views, when the item is closeted (always the case in the outfit view), there is a "Remove" button. - - The radio button and checkbox are visually hidden, and are reflected in styles that emphasize the selected item, - e.g., somewhat darker border/background, bold, etc. (See Wardrobe 2020.) +- Progressive enhancement (outfit view done, search view pending): + - In the outfit view, items in each zone group have a visually-hidden radio input inside a `