add floating navbar
This commit is contained in:
parent
7de1201675
commit
3ba462d797
|
@ -23,7 +23,7 @@ import {
|
||||||
import { MoreHorizontal } from "lucide-react";
|
import { MoreHorizontal } from "lucide-react";
|
||||||
import { InboxDrawer } from "@/components/inbox-drawer";
|
import { InboxDrawer } from "@/components/inbox-drawer";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import ActionSearchBar from "@/components/action-search-bar";
|
import FloatingActionSearchBar from "@/components/floating-action-search-bar";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
export default async function Layout({
|
export default async function Layout({
|
||||||
|
@ -37,47 +37,45 @@ export default async function Layout({
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
{/* Navigation bar with SidebarTrigger and Breadcrumbs */}
|
{/* Navigation bar with SidebarTrigger and Breadcrumbs */}
|
||||||
<nav className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<nav className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="flex h-12 items-center px-4">
|
<div className="flex h-16 shrink-0 items-center justify-end border-b px-4 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
||||||
<SidebarTrigger className="" />
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
<SidebarTrigger className="" />
|
||||||
<Breadcrumb>
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
<BreadcrumbList>
|
<Breadcrumb>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbList>
|
||||||
<BreadcrumbLink href="#">Crime Management</BreadcrumbLink>
|
<BreadcrumbItem>
|
||||||
</BreadcrumbItem>
|
<BreadcrumbLink href="#">Sigap - v</BreadcrumbLink>
|
||||||
<BreadcrumbSeparator />
|
</BreadcrumbItem>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbSeparator />
|
||||||
<BreadcrumbPage>Crime Overview</BreadcrumbPage>
|
<BreadcrumbItem>
|
||||||
</BreadcrumbItem>
|
<BreadcrumbPage>Map</BreadcrumbPage>
|
||||||
</BreadcrumbList>
|
</BreadcrumbItem>
|
||||||
</Breadcrumb>
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<InboxDrawer showTitle={true} showAvatar={false} />
|
||||||
|
<ThemeSwitcher showTitle={true} />
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="h-5 w-5" />
|
||||||
|
<span className="sr-only">More options</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Help</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>About</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Header with action bar and other controls */}
|
{/* Header with other controls */}
|
||||||
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
<header className="flex h-12 shrink-0 items-center justify-end border-b px-4 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-8"></header>
|
||||||
<div className="w-[300px]">
|
<FloatingActionSearchBar />
|
||||||
<ActionSearchBar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<InboxDrawer showTitle={true} showAvatar={false} />
|
|
||||||
<ThemeSwitcher showTitle={true} />
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<MoreHorizontal className="h-5 w-5" />
|
|
||||||
<span className="sr-only">More options</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>Help</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>About</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{children}
|
{children}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import React, { useState, useEffect, forwardRef, useImperativeHandle } from "react"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { Search, Send, BarChart2, Globe, Video, PlaneTakeoff, AudioLines } from "lucide-react"
|
import { Search, Send, BarChart2, Globe, Video, PlaneTakeoff, AudioLines } from "lucide-react"
|
||||||
|
@ -64,171 +62,179 @@ const allActions = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function ActionSearchBar({ actions = allActions }: { actions?: Action[] }) {
|
interface ActionSearchBarProps {
|
||||||
const [query, setQuery] = useState("")
|
actions?: Action[]
|
||||||
const [result, setResult] = useState<SearchResult | null>(null)
|
autoFocus?: boolean
|
||||||
const [isFocused, setIsFocused] = useState(false)
|
isFloating?: boolean
|
||||||
const [isTyping, setIsTyping] = useState(false)
|
}
|
||||||
const [selectedAction, setSelectedAction] = useState<Action | null>(null)
|
|
||||||
const debouncedQuery = useDebounce(query, 200)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const ActionSearchBar = forwardRef<HTMLInputElement, ActionSearchBarProps>(
|
||||||
if (!isFocused) {
|
({ actions = allActions, autoFocus = false, isFloating = false }, ref) => {
|
||||||
setResult(null)
|
const [query, setQuery] = useState("")
|
||||||
return
|
const [result, setResult] = useState<SearchResult | null>(null)
|
||||||
|
const [isFocused, setIsFocused] = useState(autoFocus)
|
||||||
|
const [selectedAction, setSelectedAction] = useState<Action | null>(null)
|
||||||
|
const debouncedQuery = useDebounce(query, 200)
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus && inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [autoFocus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debouncedQuery) {
|
||||||
|
setResult({ actions: allActions })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = debouncedQuery.toLowerCase().trim()
|
||||||
|
const filteredActions = allActions.filter((action) => {
|
||||||
|
const searchableText = action.label.toLowerCase()
|
||||||
|
return searchableText.includes(normalizedQuery)
|
||||||
|
})
|
||||||
|
|
||||||
|
setResult({ actions: filteredActions })
|
||||||
|
}, [debouncedQuery])
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!debouncedQuery) {
|
const container = {
|
||||||
setResult({ actions: allActions })
|
hidden: { opacity: 0, height: 0 },
|
||||||
return
|
show: {
|
||||||
}
|
opacity: 1,
|
||||||
|
height: "auto",
|
||||||
const normalizedQuery = debouncedQuery.toLowerCase().trim()
|
transition: {
|
||||||
const filteredActions = allActions.filter((action) => {
|
height: {
|
||||||
const searchableText = action.label.toLowerCase()
|
duration: 0.4,
|
||||||
return searchableText.includes(normalizedQuery)
|
},
|
||||||
})
|
staggerChildren: 0.1,
|
||||||
|
|
||||||
setResult({ actions: filteredActions })
|
|
||||||
}, [debouncedQuery, isFocused])
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setQuery(e.target.value)
|
|
||||||
setIsTyping(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = {
|
|
||||||
hidden: { opacity: 0, height: 0 },
|
|
||||||
show: {
|
|
||||||
opacity: 1,
|
|
||||||
height: "auto",
|
|
||||||
transition: {
|
|
||||||
height: {
|
|
||||||
duration: 0.4,
|
|
||||||
},
|
},
|
||||||
staggerChildren: 0.1,
|
|
||||||
},
|
},
|
||||||
},
|
exit: {
|
||||||
exit: {
|
opacity: 0,
|
||||||
opacity: 0,
|
height: 0,
|
||||||
height: 0,
|
transition: {
|
||||||
transition: {
|
height: {
|
||||||
height: {
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
duration: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
duration: 0.3,
|
duration: 0.3,
|
||||||
},
|
},
|
||||||
opacity: {
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -10,
|
||||||
|
transition: {
|
||||||
duration: 0.2,
|
duration: 0.2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const item = {
|
return (
|
||||||
hidden: { opacity: 0, y: 20 },
|
<div className={`relative w-full ${isFloating ? "bg-background rounded-lg shadow-lg" : ""}`}>
|
||||||
show: {
|
<div className="relative">
|
||||||
opacity: 1,
|
<Input
|
||||||
y: 0,
|
ref={inputRef}
|
||||||
transition: {
|
type="text"
|
||||||
duration: 0.3,
|
placeholder="What's up?"
|
||||||
},
|
value={query}
|
||||||
},
|
onChange={handleInputChange}
|
||||||
exit: {
|
onFocus={() => setIsFocused(true)}
|
||||||
opacity: 0,
|
onBlur={() => !isFloating && setTimeout(() => setIsFocused(false), 200)}
|
||||||
y: -10,
|
className="pl-3 pr-9 py-1.5 h-9 text-sm rounded-lg focus-visible:ring-offset-0"
|
||||||
transition: {
|
/>
|
||||||
duration: 0.2,
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4">
|
||||||
},
|
<AnimatePresence mode="popLayout">
|
||||||
},
|
{query.length > 0 ? (
|
||||||
}
|
<motion.div
|
||||||
|
key="send"
|
||||||
const handleFocus = () => {
|
initial={{ y: -20, opacity: 0 }}
|
||||||
setSelectedAction(null)
|
animate={{ y: 0, opacity: 1 }}
|
||||||
setIsFocused(true)
|
exit={{ y: 20, opacity: 0 }}
|
||||||
}
|
transition={{ duration: 0.2 }}
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full">
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="What's up?"
|
|
||||||
value={query}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={() => setTimeout(() => setIsFocused(false), 200)}
|
|
||||||
className="pl-3 pr-9 py-1.5 h-9 text-sm rounded-lg focus-visible:ring-offset-0"
|
|
||||||
/>
|
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4">
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{query.length > 0 ? (
|
|
||||||
<motion.div
|
|
||||||
key="send"
|
|
||||||
initial={{ y: -20, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
exit={{ y: 20, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<Send className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<motion.div
|
|
||||||
key="search"
|
|
||||||
initial={{ y: -20, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
exit={{ y: 20, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<Search className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{isFocused && result && !selectedAction && (
|
|
||||||
<motion.div
|
|
||||||
className="absolute left-0 right-0 z-50 border rounded-md shadow-lg overflow-hidden dark:border-gray-800 bg-background mt-1"
|
|
||||||
variants={container}
|
|
||||||
initial="hidden"
|
|
||||||
animate="show"
|
|
||||||
exit="exit"
|
|
||||||
>
|
|
||||||
<motion.ul>
|
|
||||||
{result.actions.map((action) => (
|
|
||||||
<motion.li
|
|
||||||
key={action.id}
|
|
||||||
className="px-3 py-2 flex items-center justify-between hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
|
||||||
variants={item}
|
|
||||||
layout
|
|
||||||
onClick={() => setSelectedAction(action)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 justify-between">
|
<Send className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||||
<div className="flex items-center gap-2">
|
</motion.div>
|
||||||
<span className="text-gray-500">{action.icon}</span>
|
) : (
|
||||||
<span className="text-sm font-medium">{action.label}</span>
|
<motion.div
|
||||||
<span className="text-xs text-muted-foreground">{action.description}</span>
|
key="search"
|
||||||
|
initial={{ y: -20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 20, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{(isFocused || isFloating) && result && !selectedAction && (
|
||||||
|
<motion.div
|
||||||
|
className={`${isFloating ? "relative mt-2" : "absolute left-0 right-0 z-50"} border rounded-md shadow-lg overflow-hidden dark:border-gray-800 bg-background`}
|
||||||
|
variants={container}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
exit="exit"
|
||||||
|
>
|
||||||
|
<motion.ul>
|
||||||
|
{result.actions.map((action) => (
|
||||||
|
<motion.li
|
||||||
|
key={action.id}
|
||||||
|
className="px-3 py-2 flex items-center justify-between hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
||||||
|
variants={item}
|
||||||
|
layout
|
||||||
|
onClick={() => setSelectedAction(action)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-500">{action.icon}</span>
|
||||||
|
<span className="text-sm font-medium">{action.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{action.description}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-xs text-muted-foreground">{action.short}</span>
|
||||||
<span className="text-xs text-muted-foreground">{action.short}</span>
|
<span className="text-xs text-muted-foreground text-right">{action.end}</span>
|
||||||
<span className="text-xs text-muted-foreground text-right">{action.end}</span>
|
</div>
|
||||||
</div>
|
</motion.li>
|
||||||
</motion.li>
|
))}
|
||||||
))}
|
</motion.ul>
|
||||||
</motion.ul>
|
<div className="mt-2 px-3 py-2 border-t border-border">
|
||||||
<div className="mt-2 px-3 py-2 border-t border-border">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<span>Press ⌘K to open commands</span>
|
||||||
<span>Press ⌘K to open commands</span>
|
<span>ESC to cancel</span>
|
||||||
<span>ESC to cancel</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</motion.div>
|
)}
|
||||||
)}
|
</AnimatePresence>
|
||||||
</AnimatePresence>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
},
|
||||||
}
|
)
|
||||||
|
|
||||||
|
ActionSearchBar.displayName = "ActionSearchBar"
|
||||||
|
|
||||||
export default ActionSearchBar
|
export default ActionSearchBar
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import ActionSearchBar from "@/components/action-search-bar";
|
||||||
|
|
||||||
|
export default function FloatingActionSearchBar() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const searchBarRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsOpen((prev) => !prev);
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && searchBarRef.current) {
|
||||||
|
searchBarRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center bg-background/80 backdrop-blur-sm p-4 sm:p-6 md:p-8 lg:p-40"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, y: -20 }}
|
||||||
|
animate={{ scale: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.95, y: -20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="w-full max-w-lg sm:max-w-xl md:max-w-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ActionSearchBar
|
||||||
|
ref={searchBarRef}
|
||||||
|
autoFocus={true}
|
||||||
|
isFloating={true}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue