diff --git a/app/assets/javascripts/wardrobe/item-card.js b/app/assets/javascripts/wardrobe/item-card.js new file mode 100644 index 00000000..78c6601a --- /dev/null +++ b/app/assets/javascripts/wardrobe/item-card.js @@ -0,0 +1,70 @@ +/** + * ItemCard web component + * + * Progressive enhancement for item cards in both outfit and search views. + * Replaces baseline Show/Hide/Add buttons with click-to-toggle behavior: + * + * Outfit view (radio inputs): + * - 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 + * - Space on a checked radio submits the Hide form (radios don't toggle natively) + * + * Search view (checkbox inputs): + * - Clicking an unworn item's label checks the checkbox and submits the Add form + * - Clicking a worn item's label prevents default and submits the Hide form + */ +class ItemCard extends HTMLElement { + connectedCallback() { + this.addEventListener("click", this.#handleClick); + this.addEventListener("keydown", this.#handleKeydown); + this.addEventListener("change", this.#handleChange); + } + + #handleClick = (e) => { + const label = e.target.closest(".item-card-label"); + if (!label) return; + + const input = label.querySelector("input[type=radio], input[type=checkbox]"); + if (!input) return; + + // If this item is worn, un-wear it by submitting the Hide form + if (this.dataset.isWorn != null) { + e.preventDefault(); + const hideButton = this.querySelector(".item-hide-button"); + if (hideButton) hideButton.closest("form").requestSubmit(); + } + // Otherwise, let the default label click proceed—it checks the input, + // which fires the `change` event handled below + }; + + #handleKeydown = (e) => { + // Spacebar on an already-checked radio: un-wear the item + if (e.key !== " ") return; + + const input = e.target; + if (input.type !== "radio" || !input.checked) return; + if (this.dataset.isWorn == null) return; + + e.preventDefault(); + const hideButton = this.querySelector(".item-hide-button"); + if (hideButton) hideButton.closest("form").requestSubmit(); + }; + + #handleChange = (e) => { + const input = e.target; + if (!input.checked) return; + if (input.type !== "radio" && input.type !== "checkbox") return; + + // Submit the Show form to wear this item, or the Add form if not closeted + const showButton = this.querySelector(".item-show-button"); + if (showButton) { + showButton.closest("form").requestSubmit(); + } else { + const addButton = this.querySelector(".item-add-button"); + if (addButton) addButton.closest("form").requestSubmit(); + } + }; +} + +customElements.define("item-card", ItemCard); diff --git a/app/assets/javascripts/wardrobe/zone-item-group.js b/app/assets/javascripts/wardrobe/zone-item-group.js deleted file mode 100644 index 2347fabb..00000000 --- a/app/assets/javascripts/wardrobe/zone-item-group.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * 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 53f529ea..dbf5d7a4 100644 --- a/app/assets/stylesheets/wardrobe/show.css +++ b/app/assets/stylesheets/wardrobe/show.css @@ -150,7 +150,7 @@ select, } /* Item card - shared layout for worn items and search results */ -.item-card { +item-card { display: flex; align-items: center; gap: 0.75rem; @@ -212,7 +212,7 @@ select, } /* Worn item emphasis */ -.item-card[data-is-worn] { +item-card[data-is-worn] { background: #eef5ee; box-shadow: inset 0 0 0 1px rgba(68, 136, 68, 0.2); @@ -222,7 +222,7 @@ select, } /* Closeted item de-emphasis */ -.item-card[data-is-closeted] { +item-card[data-is-closeted] { background: #f5f5f5; border: 1px dashed #ccc; opacity: 0.75; @@ -251,15 +251,16 @@ select, min-width: 0; } -/* When zone-item-group is defined (JS loaded), hide Show/Hide buttons +/* When item-card is defined (JS loaded), hide Show/Hide/Add 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 { +item-card:defined .item-show-button, +item-card:defined .item-hide-button, +item-card:defined .item-add-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) { +/* Focus ring on item card when input is focused (keyboard navigation) */ +item-card:defined:has(input:focus-visible) { outline: 2px solid var(--color-primary); outline-offset: -2px; } diff --git a/app/views/wardrobe/items/_item_card.html.haml b/app/views/wardrobe/items/_item_card.html.haml index 25706f27..73691680 100644 --- a/app/views/wardrobe/items/_item_card.html.haml +++ b/app/views/wardrobe/items/_item_card.html.haml @@ -1,12 +1,14 @@ - 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-card{data: {is_worn: is_worn || nil, is_closeted: is_closeted || nil}} - 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 + %label.item-card-label + %input.visually-hidden{type: "checkbox", checked: is_worn || nil, "aria-label": item.name} + = 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/show.html.haml b/app/views/wardrobe/show.html.haml index e86d23ea..6ab6865a 100644 --- a/app/views/wardrobe/show.html.haml +++ b/app/views/wardrobe/show.html.haml @@ -17,7 +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/item-card", async: true = javascript_include_tag "wardrobe/show", async: true = csrf_meta_tags %meta{name: 'outfit-viewer-morph-mode', value: 'full-page'} @@ -75,7 +75,6 @@ - outfit_items_by_zone(@outfit).each do |zone_group| .zone-group %h3.zone-label= zone_group[:zone_label] - %zone-item-group - %ul.items-list - - zone_group[:items].each do |item| - = render "items/item_card", item: item, zone_id: zone_group[:zone_id] + %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 6609a363..21255681 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 (outfit view done, search view pending): - - In the outfit view, items in each zone group have a visually-hidden radio input inside a `