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:
@@ -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>
|
|
||||||
|
|||||||
146
src/lib/components/layout/SearchModal.svelte
Normal file
146
src/lib/components/layout/SearchModal.svelte
Normal 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}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user