[WV2] Add progressive enhancement for outfit item list toggles
Rather than just buttons, upgrade to radio buttons when we have JS.
This commit is contained in:
parent
81b60eefad
commit
36a28cff10
6 changed files with 130 additions and 17 deletions
68
app/assets/javascripts/wardrobe/zone-item-group.js
Normal file
68
app/assets/javascripts/wardrobe/zone-item-group.js
Normal file
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
👁️🗨️
|
||||
|
|
|
|||
7
app/views/wardrobe/items/_item_card_content.html.haml
Normal file
7
app/views/wardrobe/items/_item_card_content.html.haml
Normal file
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
%zone-item-group
|
||||
%ul.items-list
|
||||
- zone_group[:items].each do |item|
|
||||
= render "items/item_card", item: item
|
||||
= render "items/item_card", item: item, zone_id: zone_group[:zone_id]
|
||||
|
|
|
|||
|
|
@ -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 `<label>`. The
|
||||
`<zone-item-group>` web component delegates clicks to the baseline Show/Hide forms: clicking a closeted item
|
||||
submits its Show form (wears it), clicking a worn item submits its Hide form (un-wears it). Arrow keys navigate
|
||||
between items via native radio behavior.
|
||||
- In the search view, each item will have a worn checkbox (analogous to the worn radio button) — pending.
|
||||
- In both views, when the item is closeted, there is a "Remove" button.
|
||||
- The radio button and checkbox are visually hidden, and are reflected in the item card worn/closeted styles instead.
|
||||
|
||||
### Phase 2: Polish & Parity
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue