refactor: Move search modal to layout root for proper z-index stacking

- Extract SearchModal component from SearchBar for root-level rendering
- Add isModalOpen state to search store with open/close methods
- Simplify SearchBar to trigger button only
- Update Modal with proper overflow handling and scroll-to-close
- Fix layout background to use void color

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 16:32:24 +01:00
parent d01e0d28f6
commit cdc70403f9
5 changed files with 189 additions and 107 deletions

View File

@@ -1,116 +1,30 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { Search, Command } from 'lucide-svelte'; import { Search, Command } from 'lucide-svelte';
import { search } from '$lib/stores'; import { search } from '$lib/stores';
import Modal from '$lib/components/ui/Modal.svelte';
let open = $state(false);
let query = $state('');
let searchInput: HTMLInputElement;
// Keyboard shortcut: Cmd/Ctrl + K // Keyboard shortcut: Cmd/Ctrl + K
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault(); e.preventDefault();
open = true; search.openModal();
setTimeout(() => searchInput?.focus(), 100);
} }
}; };
const handleSearch = (e: Event) => {
e.preventDefault();
if (!query.trim()) return;
// Add to recent searches
search.addRecentSearch(query);
// Navigate to matches page with search query
goto(`/matches?search=${encodeURIComponent(query)}`);
// Close modal and clear
open = false;
query = '';
};
const handleRecentClick = (recentQuery: string) => {
query = recentQuery;
handleSearch(new Event('submit'));
};
const handleClearRecent = () => {
search.clearRecentSearches();
};
</script> </script>
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
<!-- Search Button (Header) --> <!-- Search Button (Header) -->
<button <button
class="btn btn-ghost gap-2" class="flex items-center gap-2 rounded-lg px-3 py-2 text-white/70 transition-colors hover:bg-neon-blue/10 hover:text-neon-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue"
onclick={() => { onclick={() => search.openModal()}
open = true; aria-label="Search matches and players"
setTimeout(() => searchInput?.focus(), 100);
}}
aria-label="Search"
> >
<Search class="h-5 w-5" /> <Search class="h-5 w-5" aria-hidden="true" />
<span class="hidden md:inline">Search</span> <span class="hidden md:inline">Search</span>
<kbd class="kbd kbd-sm hidden lg:inline-flex"> <kbd
<Command class="h-3 w-3" /> class="hidden items-center gap-0.5 rounded border border-neon-blue/30 bg-void px-1.5 py-0.5 text-xs text-white/50 lg:inline-flex"
K >
<Command class="h-3 w-3" aria-hidden="true" />
<span>K</span>
</kbd> </kbd>
</button> </button>
<!-- Search Modal -->
<Modal bind:open size="lg">
<div class="space-y-4">
<form onsubmit={handleSearch}>
<label class="input input-bordered flex items-center gap-2">
<Search class="h-5 w-5 text-base-content/60" />
<input
bind:this={searchInput}
bind:value={query}
type="text"
class="grow"
placeholder="Search matches, players, share codes..."
autocomplete="off"
/>
<kbd class="kbd kbd-sm">
<Command class="h-3 w-3" />
K
</kbd>
</label>
</form>
<!-- Recent Searches -->
{#if $search.recentSearches.length > 0}
<div class="space-y-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-base-content/70">Recent Searches</h3>
<button class="btn btn-ghost btn-xs" onclick={handleClearRecent}>Clear</button>
</div>
<div class="flex flex-wrap gap-2">
{#each $search.recentSearches as recent}
<button
class="badge badge-outline badge-lg gap-2 hover:badge-primary"
onclick={() => handleRecentClick(recent)}
>
<Search class="h-3 w-3" />
{recent}
</button>
{/each}
</div>
</div>
{/if}
<!-- Search Tips -->
<div class="rounded-lg bg-base-200 p-4">
<h4 class="mb-2 text-sm font-semibold text-base-content">Search Tips</h4>
<ul class="space-y-1 text-xs text-base-content/70">
<li>• Search by player name or Steam ID</li>
<li>• Enter share code to find specific match</li>
<li>• Use map name to filter matches (e.g., "de_dust2")</li>
</ul>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Search, Command } from 'lucide-svelte';
import { search } from '$lib/stores';
import { fly, fade } from 'svelte/transition';
let query = $state('');
let searchInput = $state<HTMLInputElement | null>(null);
// Focus input when modal opens
$effect(() => {
if ($search.isModalOpen) {
setTimeout(() => searchInput?.focus(), 100);
}
});
const handleClose = () => {
search.closeModal();
query = '';
};
const handleBackdropClick = (e: MouseEvent) => {
if (e.target === e.currentTarget) {
handleClose();
}
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && $search.isModalOpen) {
handleClose();
}
};
const handleScroll = () => {
if ($search.isModalOpen) {
handleClose();
}
};
const handleSearch = (e: Event) => {
e.preventDefault();
if (!query.trim()) return;
search.addRecentSearch(query);
goto(`/matches?search=${encodeURIComponent(query)}`);
handleClose();
};
const handleRecentClick = (recentQuery: string) => {
query = recentQuery;
handleSearch(new Event('submit'));
};
const handleClearRecent = () => {
search.clearRecentSearches();
};
</script>
<svelte:window onkeydown={handleKeydown} onscroll={handleScroll} />
{#if $search.isModalOpen}
<div
class="fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto p-4"
transition:fade={{ duration: 200 }}
onclick={handleBackdropClick}
onkeydown={(e) => {
if (e.key === 'Escape') handleClose();
}}
role="dialog"
aria-modal="true"
aria-label="Search"
tabindex="-1"
>
<!-- Backdrop -->
<div class="pointer-events-none absolute inset-0 z-0 bg-black/70 backdrop-blur-sm"></div>
<!-- Modal -->
<div
class="relative z-10 my-auto max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl border border-neon-blue/20 bg-void shadow-2xl"
style="box-shadow: 0 0 50px rgba(0, 212, 255, 0.1);"
transition:fly={{ y: -20, duration: 300 }}
>
<!-- Content -->
<div class="space-y-4 p-6">
<form onsubmit={handleSearch}>
<label
class="flex items-center gap-3 rounded-lg border border-neon-blue/30 bg-void-light/50 px-4 py-3 transition-colors focus-within:border-neon-blue focus-within:ring-1 focus-within:ring-neon-blue"
>
<Search class="h-5 w-5 text-white/50" aria-hidden="true" />
<input
bind:this={searchInput}
bind:value={query}
type="text"
class="grow bg-transparent text-white placeholder:text-white/40 focus:outline-none"
placeholder="Search matches, players, share codes..."
autocomplete="off"
/>
<kbd
class="flex items-center gap-0.5 rounded border border-neon-blue/30 bg-void px-1.5 py-0.5 text-xs text-white/50"
>
<Command class="h-3 w-3" aria-hidden="true" />
<span>K</span>
</kbd>
</label>
</form>
<!-- Recent Searches -->
{#if $search.recentSearches.length > 0}
<div class="space-y-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white/60">Recent Searches</h3>
<button
class="rounded px-2 py-1 text-xs text-white/50 transition-colors hover:bg-neon-red/10 hover:text-neon-red focus:outline-none focus-visible:ring-1 focus-visible:ring-neon-red"
onclick={handleClearRecent}
>
Clear
</button>
</div>
<div class="flex flex-wrap gap-2">
{#each $search.recentSearches as recent}
<button
class="flex items-center gap-2 rounded-full border border-neon-blue/30 px-3 py-1.5 text-sm text-white/70 transition-colors hover:border-neon-blue hover:bg-neon-blue/10 hover:text-neon-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue"
onclick={() => handleRecentClick(recent)}
>
<Search class="h-3 w-3" aria-hidden="true" />
{recent}
</button>
{/each}
</div>
</div>
{/if}
<!-- Search Tips -->
<div class="rounded-lg border border-neon-blue/10 bg-neon-blue/5 p-4">
<h4 class="mb-2 text-sm font-semibold text-white">Search Tips</h4>
<ul class="space-y-1 text-xs text-white/50">
<li>Search by player name or Steam ID</li>
<li>Enter share code to find specific match</li>
<li>Use map name to filter matches (e.g., "de_dust2")</li>
</ul>
</div>
</div>
</div>
</div>
{/if}

View File

@@ -39,11 +39,16 @@
}; };
</script> </script>
<svelte:window onkeydown={handleKeydown} /> <svelte:window
onkeydown={handleKeydown}
onscroll={() => {
if (open) handleClose();
}}
/>
{#if open} {#if open}
<div <div
class="fixed inset-0 z-50 flex items-center justify-center p-4" class="fixed inset-0 z-[9999] flex items-center justify-center overflow-y-auto p-4"
transition:fade={{ duration: 200 }} transition:fade={{ duration: 200 }}
onclick={handleBackdropClick} onclick={handleBackdropClick}
onkeydown={(e) => { onkeydown={(e) => {
@@ -57,19 +62,22 @@
tabindex="-1" tabindex="-1"
> >
<!-- Backdrop --> <!-- Backdrop -->
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div> <div class="pointer-events-none absolute inset-0 z-0 bg-black/70 backdrop-blur-sm"></div>
<!-- Modal --> <!-- Modal -->
<div <div
class="relative w-full {sizeClasses[size]} rounded-lg bg-base-100 shadow-xl" class="relative z-10 my-auto w-full {sizeClasses[
size
]} max-h-[90vh] overflow-y-auto rounded-xl border border-neon-blue/20 bg-void shadow-2xl"
style="box-shadow: 0 0 50px rgba(0, 212, 255, 0.1);"
transition:fly={{ y: -20, duration: 300 }} transition:fly={{ y: -20, duration: 300 }}
> >
<!-- Header --> <!-- Header -->
{#if title} {#if title}
<div class="flex items-center justify-between border-b border-base-300 p-6"> <div class="flex items-center justify-between border-b border-neon-blue/20 p-6">
<h2 id="modal-title" class="text-2xl font-bold text-base-content">{title}</h2> <h2 id="modal-title" class="text-2xl font-bold text-white">{title}</h2>
<button <button
class="btn btn-circle btn-ghost btn-sm" class="rounded-lg p-2 text-white/60 transition-colors hover:bg-neon-blue/10 hover:text-neon-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue"
onclick={handleClose} onclick={handleClose}
aria-label="Close modal" aria-label="Close modal"
> >
@@ -78,7 +86,7 @@
</div> </div>
{:else} {:else}
<button <button
class="btn btn-circle btn-ghost btn-sm absolute right-4 top-4 z-10" class="absolute right-4 top-4 z-10 rounded-lg p-2 text-white/60 transition-colors hover:bg-neon-blue/10 hover:text-neon-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-neon-blue"
onclick={handleClose} onclick={handleClose}
aria-label="Close modal" aria-label="Close modal"
> >
@@ -93,7 +101,7 @@
<!-- Actions --> <!-- Actions -->
{#if actions} {#if actions}
<div class="flex justify-end gap-2 border-t border-base-300 p-6"> <div class="flex justify-end gap-2 border-t border-neon-blue/20 p-6">
{@render actions()} {@render actions()}
</div> </div>
{/if} {/if}

View File

@@ -9,6 +9,7 @@ import { browser } from '$app/environment';
export interface SearchState { export interface SearchState {
query: string; query: string;
recentSearches: string[]; recentSearches: string[];
isModalOpen: boolean;
filters: { filters: {
map?: string; map?: string;
playerId?: number; playerId?: number;
@@ -20,6 +21,7 @@ export interface SearchState {
const defaultState: SearchState = { const defaultState: SearchState = {
query: '', query: '',
recentSearches: [], recentSearches: [],
isModalOpen: false,
filters: {} filters: {}
}; };
@@ -105,6 +107,16 @@ const createSearchStore = () => {
// Reset entire search state // Reset entire search state
reset: () => { reset: () => {
set({ ...defaultState, recentSearches: loadRecentSearches() }); set({ ...defaultState, recentSearches: loadRecentSearches() });
},
// Open search modal
openModal: () => {
update((state) => ({ ...state, isModalOpen: true }));
},
// Close search modal
closeModal: () => {
update((state) => ({ ...state, isModalOpen: false }));
} }
}; };
}; };

View File

@@ -3,17 +3,19 @@
import Header from '$lib/components/layout/Header.svelte'; import Header from '$lib/components/layout/Header.svelte';
import Footer from '$lib/components/layout/Footer.svelte'; import Footer from '$lib/components/layout/Footer.svelte';
import ToastContainer from '$lib/components/ui/ToastContainer.svelte'; import ToastContainer from '$lib/components/ui/ToastContainer.svelte';
import SearchModal from '$lib/components/layout/SearchModal.svelte';
let { children } = $props(); let { children } = $props();
</script> </script>
<div class="flex min-h-screen flex-col bg-base-100"> <div class="flex min-h-screen flex-col bg-void">
<Header /> <Header />
<main class="flex-1"> <main class="flex-1">
{@render children()} {@render children()}
</main> </main>
<Footer /> <Footer />
<!-- Toast notifications --> <!-- Global overlays -->
<ToastContainer /> <ToastContainer />
<SearchModal />
</div> </div>