[WV2] Progressive enhancement for item search
This commit is contained in:
parent
36a28cff10
commit
10e2140045
6 changed files with 95 additions and 90 deletions
70
app/assets/javascripts/wardrobe/item-card.js
Normal file
70
app/assets/javascripts/wardrobe/item-card.js
Normal file
|
|
@ -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);
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -150,7 +150,7 @@ select,
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Item card - shared layout for worn items and search results */
|
/* Item card - shared layout for worn items and search results */
|
||||||
.item-card {
|
item-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|
@ -212,7 +212,7 @@ select,
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Worn item emphasis */
|
/* Worn item emphasis */
|
||||||
.item-card[data-is-worn] {
|
item-card[data-is-worn] {
|
||||||
background: #eef5ee;
|
background: #eef5ee;
|
||||||
box-shadow: inset 0 0 0 1px rgba(68, 136, 68, 0.2);
|
box-shadow: inset 0 0 0 1px rgba(68, 136, 68, 0.2);
|
||||||
|
|
||||||
|
|
@ -222,7 +222,7 @@ select,
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Closeted item de-emphasis */
|
/* Closeted item de-emphasis */
|
||||||
.item-card[data-is-closeted] {
|
item-card[data-is-closeted] {
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
border: 1px dashed #ccc;
|
border: 1px dashed #ccc;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
|
|
@ -251,15 +251,16 @@ select,
|
||||||
min-width: 0;
|
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. */
|
and make the label the primary interaction. Keep Remove visible. */
|
||||||
zone-item-group:defined .item-show-button,
|
item-card:defined .item-show-button,
|
||||||
zone-item-group:defined .item-hide-button {
|
item-card:defined .item-hide-button,
|
||||||
|
item-card:defined .item-add-button {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus ring on item card when radio is focused (keyboard navigation) */
|
/* Focus ring on item card when input is focused (keyboard navigation) */
|
||||||
zone-item-group:defined .item-card:has(input[type="radio"]:focus-visible) {
|
item-card:defined:has(input:focus-visible) {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 2px solid var(--color-primary);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
- is_worn = @outfit.worn_items.include?(item)
|
- is_worn = @outfit.worn_items.include?(item)
|
||||||
- is_closeted = @outfit.closeted_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
|
- if defined?(zone_id) && zone_id
|
||||||
%label.item-card-label
|
%label.item-card-label
|
||||||
%input.visually-hidden{type: "radio", name: "zone_#{zone_id}", checked: is_worn || nil, "aria-label": item.name}
|
%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
|
= render "wardrobe/items/item_card_content", item: item
|
||||||
- else
|
- else
|
||||||
|
%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
|
= render "wardrobe/items/item_card_content", item: item
|
||||||
- if is_worn
|
- if is_worn
|
||||||
= button_to @wardrobe_path, method: :get, class: "item-hide-button", title: "Hide #{item.name}", "aria-label": "Hide #{item.name}" do
|
= button_to @wardrobe_path, method: :get, class: "item-hide-button", title: "Hide #{item.name}", "aria-label": "Hide #{item.name}" do
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
= javascript_include_tag "pose-picker", async: true
|
= javascript_include_tag "pose-picker", async: true
|
||||||
= javascript_include_tag "tab-panel", async: true
|
= javascript_include_tag "tab-panel", async: true
|
||||||
= javascript_include_tag "outfit-rename-field", 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
|
= javascript_include_tag "wardrobe/show", async: true
|
||||||
= csrf_meta_tags
|
= csrf_meta_tags
|
||||||
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
||||||
|
|
@ -75,7 +75,6 @@
|
||||||
- outfit_items_by_zone(@outfit).each do |zone_group|
|
- outfit_items_by_zone(@outfit).each do |zone_group|
|
||||||
.zone-group
|
.zone-group
|
||||||
%h3.zone-label= zone_group[:zone_label]
|
%h3.zone-label= zone_group[:zone_label]
|
||||||
%zone-item-group
|
|
||||||
%ul.items-list
|
%ul.items-list
|
||||||
- zone_group[:items].each do |item|
|
- zone_group[:items].each do |item|
|
||||||
= render "items/item_card", item: item, zone_id: zone_group[:zone_id]
|
= 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.
|
- Visual distinction: worn items have green emphasis, closeted items have dashed border and reduced opacity.
|
||||||
- Closeted items appear in zone groups alongside worn items.
|
- Closeted items appear in zone groups alongside worn items.
|
||||||
- `closet[]` URL params, saving/loading, and search pagination all work.
|
- `closet[]` URL params, saving/loading, and search pagination all work.
|
||||||
- Progressive enhancement (outfit view done, search view pending):
|
- Progressive enhancement (done):
|
||||||
- In the outfit view, items in each zone group have a visually-hidden radio input inside a `<label>`. The
|
- Each item card is an `<item-card>` custom element with a visually-hidden input inside a `<label>`.
|
||||||
`<zone-item-group>` web component delegates clicks to the baseline Show/Hide forms: clicking a closeted item
|
- In the outfit view, items use radio inputs (mutual exclusivity within zone via `name` attribute). Arrow keys
|
||||||
submits its Show form (wears it), clicking a worn item submits its Hide form (un-wears it). Arrow keys navigate
|
navigate between items via native radio behavior.
|
||||||
between items via native radio behavior.
|
- In the search view, items use checkbox inputs (independent toggle).
|
||||||
- In the search view, each item will have a worn checkbox (analogous to the worn radio button) — pending.
|
- In both views, the `<item-card>` web component delegates clicks to the baseline forms: clicking a closeted or
|
||||||
- In both views, when the item is closeted, there is a "Remove" button.
|
absent item submits its Show/Add form (wears it), clicking a worn item submits its Hide form (un-wears it).
|
||||||
|
- When the item is closeted or worn, there is a "Remove" button.
|
||||||
- The radio button and checkbox are visually hidden, and are reflected in the item card worn/closeted styles instead.
|
- The radio button and checkbox are visually hidden, and are reflected in the item card worn/closeted styles instead.
|
||||||
|
|
||||||
### Phase 2: Polish & Parity
|
### Phase 2: Polish & Parity
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue