Progressive disclosure in search dropdown based on guess count

Shows book names only (A-Z) for the first 3 guesses, reveals Old/New
Testament groupings after 3 guesses, and full section-level groupings
in canonical Bible order after 9 guesses. Adds a status banner above
the search bar to inform players when new structure becomes visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George Powell
2026-02-22 23:19:00 -05:00
parent 1de436534c
commit ba45cbdc37
2 changed files with 248 additions and 26 deletions

View File

@@ -1,29 +1,158 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible"; import { bibleBooks, type BibleBook, type BibleSection, type Testament } from "$lib/types/bible";
import { SvelteSet } from "svelte/reactivity";
let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props(); let {
searchQuery = $bindable(""),
guessedIds,
submitGuess,
guessCount = 0,
}: {
searchQuery: string;
guessedIds: SvelteSet<string>;
submitGuess: (id: string) => void;
guessCount: number;
} = $props();
let filteredBooks = $derived( type DisplayMode = "simple" | "testament" | "sections";
const displayMode = $derived<DisplayMode>(
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple"
);
const filteredBooks = $derived(
bibleBooks.filter((book) => bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase()) book.name.toLowerCase().includes(searchQuery.toLowerCase())
) )
); );
type SimpleGroup = { books: BibleBook[] };
type TestamentGroup = {
testament: Testament;
label: string;
books: BibleBook[];
};
type SectionGroup = {
testament: Testament;
testamentLabel: string;
showTestamentHeader: boolean;
section: BibleSection;
books: BibleBook[];
};
const simpleGroup = $derived.by<SimpleGroup>(() => {
const sorted = [...filteredBooks].sort((a, b) =>
a.name.localeCompare(b.name)
);
return { books: sorted };
});
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
const old = filteredBooks
.filter((b) => b.testament === "old")
.sort((a, b) => a.name.localeCompare(b.name));
const newT = filteredBooks
.filter((b) => b.testament === "new")
.sort((a, b) => a.name.localeCompare(b.name));
const groups: TestamentGroup[] = [];
if (old.length > 0) {
groups.push({ testament: "old", label: "Old Testament", books: old });
}
if (newT.length > 0) {
groups.push({ testament: "new", label: "New Testament", books: newT });
}
return groups;
});
const sectionGroups = $derived.by<SectionGroup[]>(() => {
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
const seenKeys: Record<string, true> = {};
const orderedPairs: { testament: Testament; section: BibleSection }[] = [];
for (const book of bibleBooks) {
const key = `${book.testament}:${book.section}`;
if (!seenKeys[key]) {
seenKeys[key] = true;
orderedPairs.push({ testament: book.testament, section: book.section });
}
}
const groups: SectionGroup[] = [];
let lastTestament: Testament | null = null;
for (const pair of orderedPairs) {
const books = filteredBooks.filter(
(b) => b.testament === pair.testament && b.section === pair.section
);
if (books.length === 0) continue;
const showTestamentHeader = pair.testament !== lastTestament;
lastTestament = pair.testament;
groups.push({
testament: pair.testament,
testamentLabel:
pair.testament === "old" ? "Old Testament" : "New Testament",
showTestamentHeader,
section: pair.section,
books,
});
}
return groups;
});
// First book in display order for Enter key submission
const firstBookId = $derived.by<string | null>(() => {
if (filteredBooks.length === 0) return null;
if (displayMode === "simple") {
return simpleGroup.books[0]?.id ?? null;
}
if (displayMode === "testament") {
return testamentGroups[0]?.books[0]?.id ?? null;
}
return sectionGroups[0]?.books[0]?.id ?? null;
});
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && filteredBooks.length > 0) { if (e.key === "Enter" && firstBookId) {
submitGuess(filteredBooks[0].id); submitGuess(firstBookId);
} }
} }
const showBanner = $derived(guessCount >= 3);
const bannerIsIndigo = $derived(guessCount >= 9);
</script> </script>
{#if showBanner}
<div
class="mb-3 flex items-center gap-2 px-4 py-2 rounded-full text-xs font-medium border w-fit transition-all duration-300
{bannerIsIndigo
? 'bg-indigo-50 border-indigo-200 text-indigo-700'
: 'bg-amber-50 border-amber-200 text-amber-700'}"
role="status"
aria-live="polite"
>
<span aria-hidden="true" class="text-[10px] leading-none"></span>
{#if bannerIsIndigo}
Testament &amp; section groups now visible
{:else}
Old &amp; New Testament groups now visible
{/if}
</div>
{/if}
<div class="relative"> <div class="relative">
<div class="relative"> <div class="relative">
<svg <svg
class="absolute left-4 sm:left-6 top-1/2 transform -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400" class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@@ -41,7 +170,7 @@
/> />
{#if searchQuery} {#if searchQuery}
<button <button
class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors" class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
onclick={() => (searchQuery = "")} onclick={() => (searchQuery = "")}
aria-label="Clear search" aria-label="Clear search"
> >
@@ -51,6 +180,7 @@
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@@ -62,29 +192,120 @@
</button> </button>
{/if} {/if}
</div> </div>
{#if searchQuery && filteredBooks.length > 0} {#if searchQuery && filteredBooks.length > 0}
<ul <ul
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl" class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
role="listbox"
> >
{#each filteredBooks as book (book.id)} {#if displayMode === "simple"}
<li> {#each simpleGroup.books as book (book.id)}
<button <li role="option" aria-selected={guessedIds.has(book.id)}>
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id) <button
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600' class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center" {guessedIds.has(book.id)
onclick={() => submitGuess(book.id)} ? 'opacity-50 cursor-not-allowed pointer-events-none'
> : 'hover:bg-blue-50 hover:text-blue-700'}"
<span onclick={() => submitGuess(book.id)}
class="font-semibold {guessedIds.has(book.id) tabindex={guessedIds.has(book.id) ? -1 : 0}
? 'line-through text-gray-500'
: ''}">{book.name}</span
> >
<span class="ml-auto text-sm opacity-75" <span
>({book.testament.toUpperCase()})</span class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400'
: ''}"
>
{book.name}
</span>
</button>
</li>
{/each}
{:else if displayMode === "testament"}
{#each testamentGroups as group (group.testament)}
<li role="presentation">
<div
class="px-5 py-2 flex items-center gap-3 bg-gray-50 border-b border-gray-100"
> >
</button> <span
</li> class="text-xs font-semibold uppercase tracking-wider text-gray-400"
{/each} >
{group.label}
</span>
<div class="flex-1 h-px bg-gray-200"></div>
</div>
<ul>
{#each group.books as book (book.id)}
<li role="option" aria-selected={guessedIds.has(book.id)}>
<button
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
{guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 hover:text-blue-700'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400'
: ''}"
>
{book.name}
</span>
</button>
</li>
{/each}
</ul>
</li>
{/each}
{:else}
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
<li role="presentation">
{#if group.showTestamentHeader}
<div
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 border-b border-gray-100"
>
<span
class="text-xs font-bold uppercase tracking-wider text-gray-500"
>
{group.testamentLabel}
</span>
<div class="flex-1 h-px bg-gray-200"></div>
</div>
{/if}
<div
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 border-b border-gray-100"
>
<span
class="text-[11px] font-medium uppercase tracking-wider text-gray-400"
>
{group.section}
</span>
<div class="flex-1 h-px bg-gray-100"></div>
</div>
<ul>
{#each group.books as book (book.id)}
<li role="option" aria-selected={guessedIds.has(book.id)}>
<button
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
{guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 hover:text-blue-700'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400'
: ''}"
>
{book.name}
</span>
</button>
</li>
{/each}
</ul>
</li>
{/each}
{/if}
</ul> </ul>
{:else if searchQuery} {:else if searchQuery}
<p class="mt-4 text-center text-gray-500 p-8">No books found</p> <p class="mt-4 text-center text-gray-500 p-8">No books found</p>

View File

@@ -25,6 +25,7 @@
type StatsData, type StatsData,
} from "$lib/utils/stats-client"; } from "$lib/utils/stats-client";
import { createGamePersistence } from "$lib/stores/game-persistence.svelte"; import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
import { SvelteSet } from "svelte/reactivity";
let { data }: PageProps = $props(); let { data }: PageProps = $props();
@@ -51,7 +52,7 @@
); );
let guessedIds = $derived( let guessedIds = $derived(
new Set(persistence.guesses.map((g) => g.book.id)), new SvelteSet(persistence.guesses.map((g) => g.book.id)),
); );
const currentDate = $derived( const currentDate = $derived(
@@ -302,7 +303,7 @@
{#if !isWon} {#if !isWon}
<div class="animate-fade-in-up animate-delay-400"> <div class="animate-fade-in-up animate-delay-400">
<SearchInput bind:searchQuery {guessedIds} {submitGuess} /> <SearchInput bind:searchQuery {guessedIds} {submitGuess} guessCount={persistence.guesses.length} />
</div> </div>
{:else if showWinScreen} {:else if showWinScreen}
<div class="animate-fade-in-up animate-delay-400"> <div class="animate-fade-in-up animate-delay-400">