feat: wire Build Your Own tab into RecipesView and add sparse-result nudge
This commit is contained in:
parent
40a12764c4
commit
fe18fb48c0
1 changed files with 139 additions and 41 deletions
|
|
@ -21,6 +21,7 @@
|
||||||
v-if="activeTab === 'browse'"
|
v-if="activeTab === 'browse'"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby="tab-browse"
|
aria-labelledby="tab-browse"
|
||||||
|
tabindex="0"
|
||||||
@open-recipe="openRecipeById"
|
@open-recipe="openRecipeById"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
v-else-if="activeTab === 'saved'"
|
v-else-if="activeTab === 'saved'"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby="tab-saved"
|
aria-labelledby="tab-saved"
|
||||||
|
tabindex="0"
|
||||||
@open-recipe="openRecipeById"
|
@open-recipe="openRecipeById"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -37,11 +39,20 @@
|
||||||
v-else-if="activeTab === 'community'"
|
v-else-if="activeTab === 'community'"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby="tab-community"
|
aria-labelledby="tab-community"
|
||||||
|
tabindex="0"
|
||||||
@plan-forked="onPlanForked"
|
@plan-forked="onPlanForked"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Build Your Own tab -->
|
||||||
|
<BuildYourOwnTab
|
||||||
|
v-else-if="activeTab === 'build'"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-build"
|
||||||
|
tabindex="0"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Find tab (existing search UI) -->
|
<!-- Find tab (existing search UI) -->
|
||||||
<div v-else role="tabpanel" aria-labelledby="tab-find">
|
<div v-else ref="findPanelRef" role="tabpanel" aria-labelledby="tab-find" tabindex="0">
|
||||||
<!-- Controls Panel -->
|
<!-- Controls Panel -->
|
||||||
<div class="card mb-controls">
|
<div class="card mb-controls">
|
||||||
<h2 class="section-title text-xl mb-md">Find Recipes</h2>
|
<h2 class="section-title text-xl mb-md">Find Recipes</h2>
|
||||||
|
|
@ -55,6 +66,7 @@
|
||||||
:key="lvl.value"
|
:key="lvl.value"
|
||||||
:class="['btn', 'btn-secondary', { active: recipesStore.level === lvl.value }]"
|
:class="['btn', 'btn-secondary', { active: recipesStore.level === lvl.value }]"
|
||||||
@click="recipesStore.level = lvl.value"
|
@click="recipesStore.level = lvl.value"
|
||||||
|
:aria-pressed="recipesStore.level === lvl.value"
|
||||||
:title="lvl.description"
|
:title="lvl.description"
|
||||||
>
|
>
|
||||||
{{ lvl.label }}
|
{{ lvl.label }}
|
||||||
|
|
@ -85,8 +97,8 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Dietary Preferences (collapsible) -->
|
<!-- Dietary Preferences (collapsible) -->
|
||||||
<details class="collapsible form-group">
|
<details class="collapsible form-group" @toggle="(e: Event) => dietaryOpen = (e.target as HTMLDetailsElement).open">
|
||||||
<summary class="collapsible-summary filter-summary">
|
<summary class="collapsible-summary filter-summary" :aria-expanded="dietaryOpen">
|
||||||
Dietary preferences
|
Dietary preferences
|
||||||
<span v-if="dietaryActive" class="filter-active-dot" aria-label="filters active"></span>
|
<span v-if="dietaryActive" class="filter-active-dot" aria-label="filters active"></span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
@ -162,8 +174,9 @@
|
||||||
|
|
||||||
<!-- Max Missing — hidden in shopping mode -->
|
<!-- Max Missing — hidden in shopping mode -->
|
||||||
<div v-if="!recipesStore.shoppingMode" class="form-group">
|
<div v-if="!recipesStore.shoppingMode" class="form-group">
|
||||||
<label class="form-label">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
|
<label class="form-label" for="max-missing">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
|
||||||
<input
|
<input
|
||||||
|
id="max-missing"
|
||||||
type="number"
|
type="number"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
min="0"
|
min="0"
|
||||||
|
|
@ -177,8 +190,8 @@
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- Advanced Filters (collapsible) -->
|
<!-- Advanced Filters (collapsible) -->
|
||||||
<details class="collapsible form-group">
|
<details class="collapsible form-group" @toggle="(e: Event) => advancedOpen = (e.target as HTMLDetailsElement).open">
|
||||||
<summary class="collapsible-summary filter-summary">
|
<summary class="collapsible-summary filter-summary" :aria-expanded="advancedOpen">
|
||||||
Advanced filters
|
Advanced filters
|
||||||
<span v-if="advancedActive" class="filter-active-dot" aria-label="filters active"></span>
|
<span v-if="advancedActive" class="filter-active-dot" aria-label="filters active"></span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
@ -189,26 +202,26 @@
|
||||||
<label class="form-label">Nutrition limits <span class="text-muted text-xs">(per recipe, optional)</span></label>
|
<label class="form-label">Nutrition limits <span class="text-muted text-xs">(per recipe, optional)</span></label>
|
||||||
<div class="nutrition-filters-grid mt-xs">
|
<div class="nutrition-filters-grid mt-xs">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Max Calories</label>
|
<label class="form-label" for="filter-max-cal">Max Calories</label>
|
||||||
<input type="number" class="form-input" min="0" placeholder="e.g. 600"
|
<input id="filter-max-cal" type="number" class="form-input" min="0" placeholder="e.g. 600"
|
||||||
:value="recipesStore.nutritionFilters.max_calories ?? ''"
|
:value="recipesStore.nutritionFilters.max_calories ?? ''"
|
||||||
@input="onNutritionInput('max_calories', $event)" />
|
@input="onNutritionInput('max_calories', $event)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Max Sugar (g)</label>
|
<label class="form-label" for="filter-max-sugar">Max Sugar (g)</label>
|
||||||
<input type="number" class="form-input" min="0" placeholder="e.g. 10"
|
<input id="filter-max-sugar" type="number" class="form-input" min="0" placeholder="e.g. 10"
|
||||||
:value="recipesStore.nutritionFilters.max_sugar_g ?? ''"
|
:value="recipesStore.nutritionFilters.max_sugar_g ?? ''"
|
||||||
@input="onNutritionInput('max_sugar_g', $event)" />
|
@input="onNutritionInput('max_sugar_g', $event)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Max Carbs (g)</label>
|
<label class="form-label" for="filter-max-carbs">Max Carbs (g)</label>
|
||||||
<input type="number" class="form-input" min="0" placeholder="e.g. 50"
|
<input id="filter-max-carbs" type="number" class="form-input" min="0" placeholder="e.g. 50"
|
||||||
:value="recipesStore.nutritionFilters.max_carbs_g ?? ''"
|
:value="recipesStore.nutritionFilters.max_carbs_g ?? ''"
|
||||||
@input="onNutritionInput('max_carbs_g', $event)" />
|
@input="onNutritionInput('max_carbs_g', $event)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Max Sodium (mg)</label>
|
<label class="form-label" for="filter-max-sodium">Max Sodium (mg)</label>
|
||||||
<input type="number" class="form-input" min="0" placeholder="e.g. 800"
|
<input id="filter-max-sodium" type="number" class="form-input" min="0" placeholder="e.g. 800"
|
||||||
:value="recipesStore.nutritionFilters.max_sodium_mg ?? ''"
|
:value="recipesStore.nutritionFilters.max_sodium_mg ?? ''"
|
||||||
@input="onNutritionInput('max_sodium_mg', $event)" />
|
@input="onNutritionInput('max_sodium_mg', $event)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -227,14 +240,16 @@
|
||||||
:key="style.id"
|
:key="style.id"
|
||||||
:class="['btn', 'btn-secondary', 'btn-sm', { active: recipesStore.styleId === style.id }]"
|
:class="['btn', 'btn-secondary', 'btn-sm', { active: recipesStore.styleId === style.id }]"
|
||||||
@click="recipesStore.styleId = recipesStore.styleId === style.id ? null : style.id"
|
@click="recipesStore.styleId = recipesStore.styleId === style.id ? null : style.id"
|
||||||
|
:aria-pressed="recipesStore.styleId === style.id"
|
||||||
>{{ style.label }}</button>
|
>{{ style.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category Filter (Level 1–2 only) -->
|
<!-- Category Filter (Level 1–2 only) -->
|
||||||
<div v-if="recipesStore.level <= 2" class="form-group">
|
<div v-if="recipesStore.level <= 2" class="form-group">
|
||||||
<label class="form-label">Category <span class="text-muted text-xs">(optional)</span></label>
|
<label class="form-label" for="adv-category">Category <span class="text-muted text-xs">(optional)</span></label>
|
||||||
<input
|
<input
|
||||||
|
id="adv-category"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
v-model="categoryInput"
|
v-model="categoryInput"
|
||||||
placeholder="e.g. Breakfast, Asian, Chicken, < 30 Mins"
|
placeholder="e.g. Breakfast, Asian, Chicken, < 30 Mins"
|
||||||
|
|
@ -272,26 +287,27 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div v-if="recipesStore.error" class="status-badge status-error mb-md">
|
<div v-if="recipesStore.error" role="alert" class="status-badge status-error mb-md">
|
||||||
{{ recipesStore.error }}
|
{{ recipesStore.error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Screen reader announcement when results load -->
|
<!-- Screen reader announcement for loading + results -->
|
||||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||||
<span v-if="recipesStore.result && !recipesStore.loading">
|
<span v-if="recipesStore.loading">Finding recipes…</span>
|
||||||
|
<span v-else-if="recipesStore.result">
|
||||||
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
|
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
<div v-if="recipesStore.result" class="results-section fade-in">
|
<div v-if="recipesStore.result" class="results-section fade-in" :aria-busy="recipesStore.loading">
|
||||||
<!-- Rate limit warning -->
|
<!-- Rate limit warning -->
|
||||||
<div
|
<div
|
||||||
v-if="recipesStore.result.rate_limited"
|
v-if="recipesStore.result.rate_limited"
|
||||||
class="status-badge status-warning rate-limit-banner mb-md"
|
class="status-badge status-warning rate-limit-banner mb-md"
|
||||||
>
|
>
|
||||||
You've used your {{ recipesStore.result.rate_limit_count }} free suggestions today. Upgrade for
|
Today's free suggestions are used up. Your limit resets tomorrow, or
|
||||||
unlimited.
|
<a href="#" class="link-inline" @click.prevent="$emit('upgrade')">upgrade for unlimited access</a>.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Element gaps -->
|
<!-- Element gaps -->
|
||||||
|
|
@ -320,22 +336,26 @@
|
||||||
v-for="lvl in availableLevels"
|
v-for="lvl in availableLevels"
|
||||||
:key="lvl"
|
:key="lvl"
|
||||||
:class="['filter-chip', { active: filterLevel === lvl }]"
|
:class="['filter-chip', { active: filterLevel === lvl }]"
|
||||||
|
:aria-pressed="filterLevel === lvl"
|
||||||
@click="filterLevel = filterLevel === lvl ? null : lvl"
|
@click="filterLevel = filterLevel === lvl ? null : lvl"
|
||||||
>Lv{{ lvl }}</button>
|
>{{ levelLabels[lvl] ?? `Level ${lvl}` }}</button>
|
||||||
</template>
|
</template>
|
||||||
<button
|
<button
|
||||||
:class="['filter-chip', { active: filterMissing === 0 }]"
|
:class="['filter-chip', { active: filterMissing === 0 }]"
|
||||||
|
:aria-pressed="filterMissing === 0"
|
||||||
@click="filterMissing = filterMissing === 0 ? null : 0"
|
@click="filterMissing = filterMissing === 0 ? null : 0"
|
||||||
>Can make now</button>
|
>Can make now</button>
|
||||||
<button
|
<button
|
||||||
:class="['filter-chip', { active: filterMissing === 2 }]"
|
:class="['filter-chip', { active: filterMissing === 2 }]"
|
||||||
|
:aria-pressed="filterMissing === 2"
|
||||||
@click="filterMissing = filterMissing === 2 ? null : 2"
|
@click="filterMissing = filterMissing === 2 ? null : 2"
|
||||||
>≤2 missing</button>
|
>≤2 missing</button>
|
||||||
<button
|
<button
|
||||||
v-if="hasActiveFilters"
|
v-if="hasActiveFilters"
|
||||||
class="filter-chip filter-chip-clear"
|
class="filter-chip filter-chip-clear"
|
||||||
|
aria-label="Clear all recipe filters"
|
||||||
@click="clearFilters"
|
@click="clearFilters"
|
||||||
>✕ Clear</button>
|
><span aria-hidden="true">✕</span> Clear</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -348,7 +368,7 @@
|
||||||
<p>No recipes match your filters.</p>
|
<p>No recipes match your filters.</p>
|
||||||
<button class="btn btn-ghost btn-sm mt-xs" @click="clearFilters">Clear filters</button>
|
<button class="btn btn-ghost btn-sm mt-xs" @click="clearFilters">Clear filters</button>
|
||||||
</template>
|
</template>
|
||||||
<p v-else>No recipes found for your current pantry and settings. Try lowering the creativity level or adding more items.</p>
|
<p v-else>We didn't find matches at this level. Try <button class="btn btn-ghost btn-sm" @click="recipesStore.level = 1; handleSuggest()">Level 1 — Use What I Have</button> or adjust your filters.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recipe Cards -->
|
<!-- Recipe Cards -->
|
||||||
|
|
@ -398,28 +418,28 @@
|
||||||
<!-- Nutrition chips -->
|
<!-- Nutrition chips -->
|
||||||
<div v-if="recipe.nutrition" class="nutrition-chips mb-sm">
|
<div v-if="recipe.nutrition" class="nutrition-chips mb-sm">
|
||||||
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip">
|
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip">
|
||||||
🔥 {{ Math.round(recipe.nutrition.calories) }} kcal
|
<span aria-hidden="true">🔥</span><span class="sr-only">Calories:</span> {{ Math.round(recipe.nutrition.calories) }} kcal
|
||||||
</span>
|
</span>
|
||||||
<span v-if="recipe.nutrition.fat_g != null" class="nutrition-chip">
|
<span v-if="recipe.nutrition.fat_g != null" class="nutrition-chip">
|
||||||
🧈 {{ recipe.nutrition.fat_g.toFixed(1) }}g fat
|
<span aria-hidden="true">🧈</span><span class="sr-only">Fat:</span> {{ recipe.nutrition.fat_g.toFixed(1) }}g fat
|
||||||
</span>
|
</span>
|
||||||
<span v-if="recipe.nutrition.protein_g != null" class="nutrition-chip">
|
<span v-if="recipe.nutrition.protein_g != null" class="nutrition-chip">
|
||||||
💪 {{ recipe.nutrition.protein_g.toFixed(1) }}g protein
|
<span aria-hidden="true">💪</span><span class="sr-only">Protein:</span> {{ recipe.nutrition.protein_g.toFixed(1) }}g protein
|
||||||
</span>
|
</span>
|
||||||
<span v-if="recipe.nutrition.carbs_g != null" class="nutrition-chip">
|
<span v-if="recipe.nutrition.carbs_g != null" class="nutrition-chip">
|
||||||
🌾 {{ recipe.nutrition.carbs_g.toFixed(1) }}g carbs
|
<span aria-hidden="true">🌾</span><span class="sr-only">Carbs:</span> {{ recipe.nutrition.carbs_g.toFixed(1) }}g carbs
|
||||||
</span>
|
</span>
|
||||||
<span v-if="recipe.nutrition.fiber_g != null" class="nutrition-chip">
|
<span v-if="recipe.nutrition.fiber_g != null" class="nutrition-chip">
|
||||||
🌿 {{ recipe.nutrition.fiber_g.toFixed(1) }}g fiber
|
<span aria-hidden="true">🌿</span><span class="sr-only">Fiber:</span> {{ recipe.nutrition.fiber_g.toFixed(1) }}g fiber
|
||||||
</span>
|
</span>
|
||||||
<span v-if="recipe.nutrition.sugar_g != null" class="nutrition-chip nutrition-chip-sugar">
|
<span v-if="recipe.nutrition.sugar_g != null" class="nutrition-chip nutrition-chip-sugar">
|
||||||
🍬 {{ recipe.nutrition.sugar_g.toFixed(1) }}g sugar
|
<span aria-hidden="true">🍬</span><span class="sr-only">Sugar:</span> {{ recipe.nutrition.sugar_g.toFixed(1) }}g sugar
|
||||||
</span>
|
</span>
|
||||||
<span v-if="recipe.nutrition.sodium_mg != null" class="nutrition-chip">
|
<span v-if="recipe.nutrition.sodium_mg != null" class="nutrition-chip">
|
||||||
🧂 {{ Math.round(recipe.nutrition.sodium_mg) }}mg sodium
|
<span aria-hidden="true">🧂</span><span class="sr-only">Sodium:</span> {{ Math.round(recipe.nutrition.sodium_mg) }}mg sodium
|
||||||
</span>
|
</span>
|
||||||
<span v-if="recipe.nutrition.servings != null" class="nutrition-chip nutrition-chip-servings">
|
<span v-if="recipe.nutrition.servings != null" class="nutrition-chip nutrition-chip-servings">
|
||||||
🍽️ {{ recipe.nutrition.servings }} serving{{ recipe.nutrition.servings !== 1 ? 's' : '' }}
|
<span aria-hidden="true">🍽️</span><span class="sr-only">Servings:</span> {{ recipe.nutrition.servings }} serving{{ recipe.nutrition.servings !== 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="recipe.nutrition.estimated" class="nutrition-chip nutrition-chip-estimated" title="Estimated from ingredient profiles">
|
<span v-if="recipe.nutrition.estimated" class="nutrition-chip nutrition-chip-estimated" title="Estimated from ingredient profiles">
|
||||||
~ estimated
|
~ estimated
|
||||||
|
|
@ -428,7 +448,7 @@
|
||||||
|
|
||||||
<!-- Missing ingredients -->
|
<!-- Missing ingredients -->
|
||||||
<div v-if="recipe.missing_ingredients.length > 0" class="mb-sm">
|
<div v-if="recipe.missing_ingredients.length > 0" class="mb-sm">
|
||||||
<p class="text-sm font-semibold text-warning">You'd need:</p>
|
<p class="text-sm font-semibold text-secondary">You'd need:</p>
|
||||||
<div class="flex flex-wrap gap-xs mt-xs">
|
<div class="flex flex-wrap gap-xs mt-xs">
|
||||||
<span
|
<span
|
||||||
v-for="ing in recipe.missing_ingredients"
|
v-for="ing in recipe.missing_ingredients"
|
||||||
|
|
@ -456,8 +476,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Swap candidates collapsible -->
|
<!-- Swap candidates collapsible -->
|
||||||
<details v-if="recipe.swap_candidates.length > 0" class="collapsible mb-sm">
|
<details
|
||||||
<summary class="text-sm font-semibold collapsible-summary">
|
v-if="recipe.swap_candidates.length > 0"
|
||||||
|
class="collapsible mb-sm"
|
||||||
|
@toggle="(e: Event) => toggleSwapOpen(recipe.id, (e.target as HTMLDetailsElement).open)"
|
||||||
|
>
|
||||||
|
<summary class="text-sm font-semibold collapsible-summary" :aria-expanded="openSwapIds.has(recipe.id)">
|
||||||
Possible swaps ({{ recipe.swap_candidates.length }})
|
Possible swaps ({{ recipe.swap_candidates.length }})
|
||||||
</summary>
|
</summary>
|
||||||
<div class="card-secondary mt-xs">
|
<div class="card-secondary mt-xs">
|
||||||
|
|
@ -518,6 +542,16 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Soft Build Your Own nudge when corpus results are sparse -->
|
||||||
|
<div
|
||||||
|
v-if="!recipesStore.loading && filteredSuggestions.length <= 2 && recipesStore.result"
|
||||||
|
class="byo-nudge text-sm text-secondary mt-md"
|
||||||
|
>
|
||||||
|
Not finding what you want?
|
||||||
|
<button class="btn-link" @click="activeTab = 'build'">Try Build Your Own</button>
|
||||||
|
to make something from scratch.
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recipe detail panel — mounts as a full-screen overlay -->
|
<!-- Recipe detail panel — mounts as a full-screen overlay -->
|
||||||
|
|
@ -544,6 +578,11 @@
|
||||||
|
|
||||||
</div><!-- end Find tab -->
|
</div><!-- end Find tab -->
|
||||||
|
|
||||||
|
<!-- Recipe load error — announced to screen readers via aria-live -->
|
||||||
|
<div v-if="browserRecipeError" role="alert" class="status-badge status-error mb-sm">
|
||||||
|
{{ browserRecipeError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Detail panel for browser/saved recipe lookups -->
|
<!-- Detail panel for browser/saved recipe lookups -->
|
||||||
<RecipeDetailPanel
|
<RecipeDetailPanel
|
||||||
v-if="browserSelectedRecipe"
|
v-if="browserSelectedRecipe"
|
||||||
|
|
@ -556,13 +595,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
import { useRecipesStore } from '../stores/recipes'
|
import { useRecipesStore } from '../stores/recipes'
|
||||||
import { useInventoryStore } from '../stores/inventory'
|
import { useInventoryStore } from '../stores/inventory'
|
||||||
import RecipeDetailPanel from './RecipeDetailPanel.vue'
|
import RecipeDetailPanel from './RecipeDetailPanel.vue'
|
||||||
import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
|
import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
|
||||||
import SavedRecipesPanel from './SavedRecipesPanel.vue'
|
import SavedRecipesPanel from './SavedRecipesPanel.vue'
|
||||||
import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
||||||
|
import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
||||||
import type { ForkResult } from '../stores/community'
|
import type { ForkResult } from '../stores/community'
|
||||||
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
||||||
import { recipesAPI } from '../services/api'
|
import { recipesAPI } from '../services/api'
|
||||||
|
|
@ -571,24 +611,37 @@ const recipesStore = useRecipesStore()
|
||||||
const inventoryStore = useInventoryStore()
|
const inventoryStore = useInventoryStore()
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
type TabId = 'find' | 'browse' | 'saved' | 'community'
|
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
|
||||||
const tabs: Array<{ id: TabId; label: string }> = [
|
const tabs: Array<{ id: TabId; label: string }> = [
|
||||||
{ id: 'find', label: 'Find' },
|
{ id: 'find', label: 'Find' },
|
||||||
{ id: 'browse', label: 'Browse' },
|
{ id: 'browse', label: 'Browse' },
|
||||||
{ id: 'saved', label: 'Saved' },
|
{ id: 'saved', label: 'Saved' },
|
||||||
{ id: 'community', label: 'Community' },
|
{ id: 'community', label: 'Community' },
|
||||||
|
{ id: 'build', label: 'Build Your Own' },
|
||||||
]
|
]
|
||||||
const activeTab = ref<TabId>('find')
|
const activeTab = ref<TabId>('find')
|
||||||
|
// Template ref for the Find-tab panel div (used for focus management on tab switch)
|
||||||
|
const findPanelRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
function onTabKeydown(e: KeyboardEvent) {
|
function onTabKeydown(e: KeyboardEvent) {
|
||||||
const tabIds: TabId[] = ['find', 'browse', 'saved', 'community']
|
const tabIds: TabId[] = ['find', 'browse', 'saved', 'community', 'build']
|
||||||
const current = tabIds.indexOf(activeTab.value)
|
const current = tabIds.indexOf(activeTab.value)
|
||||||
if (e.key === 'ArrowRight') {
|
if (e.key === 'ArrowRight') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
activeTab.value = tabIds[(current + 1) % tabIds.length]!
|
activeTab.value = tabIds[(current + 1) % tabIds.length]!
|
||||||
|
nextTick(() => {
|
||||||
|
// Move focus to the newly active panel so keyboard users don't have to Tab
|
||||||
|
// through the entire tab bar again to reach the panel content
|
||||||
|
const panel = document.querySelector('[role="tabpanel"]') as HTMLElement | null
|
||||||
|
panel?.focus()
|
||||||
|
})
|
||||||
} else if (e.key === 'ArrowLeft') {
|
} else if (e.key === 'ArrowLeft') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
activeTab.value = tabIds[(current - 1 + tabIds.length) % tabIds.length]!
|
activeTab.value = tabIds[(current - 1 + tabIds.length) % tabIds.length]!
|
||||||
|
nextTick(() => {
|
||||||
|
const panel = document.querySelector('[role="tabpanel"]') as HTMLElement | null
|
||||||
|
panel?.focus()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -600,14 +653,39 @@ function onPlanForked(_payload: ForkResult) {
|
||||||
// Browser/saved tab recipe detail panel (fetches full recipe from API)
|
// Browser/saved tab recipe detail panel (fetches full recipe from API)
|
||||||
const browserSelectedRecipe = ref<RecipeSuggestion | null>(null)
|
const browserSelectedRecipe = ref<RecipeSuggestion | null>(null)
|
||||||
|
|
||||||
|
const browserRecipeError = ref<string | null>(null)
|
||||||
|
|
||||||
async function openRecipeById(recipeId: number) {
|
async function openRecipeById(recipeId: number) {
|
||||||
|
browserRecipeError.value = null
|
||||||
try {
|
try {
|
||||||
browserSelectedRecipe.value = await recipesAPI.getRecipe(recipeId)
|
browserSelectedRecipe.value = await recipesAPI.getRecipe(recipeId)
|
||||||
} catch {
|
} catch {
|
||||||
// silently ignore — recipe may not exist
|
browserRecipeError.value = 'Could not load this recipe. Please try again.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collapsible panel open state — kept in sync with DOM toggle event so
|
||||||
|
// :aria-expanded on <summary> reflects the actual open/closed state
|
||||||
|
const dietaryOpen = ref(false)
|
||||||
|
const advancedOpen = ref(false)
|
||||||
|
|
||||||
|
// Per-recipe swap section open tracking (Set of recipe IDs whose swap <details> are open)
|
||||||
|
// Uses reassignment instead of .add()/.delete() so Vue's ref() detects the change
|
||||||
|
const openSwapIds = ref<Set<number>>(new Set())
|
||||||
|
function toggleSwapOpen(recipeId: number, isOpen: boolean) {
|
||||||
|
const next = new Set(openSwapIds.value)
|
||||||
|
isOpen ? next.add(recipeId) : next.delete(recipeId)
|
||||||
|
openSwapIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable level labels for filter chips (avoids "Lv1" etc. for screen readers)
|
||||||
|
const levelLabels: Record<number, string> = {
|
||||||
|
1: 'Use What I Have',
|
||||||
|
2: 'Allow Swaps',
|
||||||
|
3: 'Get Creative',
|
||||||
|
4: 'Surprise Me',
|
||||||
|
}
|
||||||
|
|
||||||
// Local input state for tags
|
// Local input state for tags
|
||||||
const constraintInput = ref('')
|
const constraintInput = ref('')
|
||||||
const allergyInput = ref('')
|
const allergyInput = ref('')
|
||||||
|
|
@ -845,6 +923,20 @@ onMounted(async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.byo-nudge {
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
padding-bottom: var(--spacing-sm);
|
padding-bottom: var(--spacing-sm);
|
||||||
|
|
@ -939,7 +1031,10 @@ onMounted(async () => {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
/* WCAG 2.2 2.5.8: minimum 24×24px touch target */
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 4px 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|
@ -958,7 +1053,10 @@ onMounted(async () => {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
/* WCAG 2.2 2.5.8: minimum 24×24px touch target */
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 4px 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue