css refactor
This commit is contained in:
@@ -6,19 +6,21 @@
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> page store
|
||||
* @INVARIANT: Always shows current page path
|
||||
*
|
||||
*
|
||||
* @UX_STATE: Idle -> Breadcrumbs showing current path
|
||||
* @UX_FEEDBACK: Hover on breadcrumb shows clickable state
|
||||
* @UX_RECOVERY: Click breadcrumb to navigate
|
||||
*/
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { t, _ } from '$lib/i18n';
|
||||
import { page } from "$app/stores";
|
||||
import { t, _ } from "$lib/i18n";
|
||||
|
||||
export let maxVisible = 3;
|
||||
let { maxVisible = 3 } = $props();
|
||||
|
||||
// Breadcrumb items derived from current path
|
||||
$: breadcrumbItems = getBreadcrumbs($page?.url?.pathname || '/', maxVisible);
|
||||
let breadcrumbItems = $derived(
|
||||
getBreadcrumbs($page?.url?.pathname || "/", maxVisible),
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate breadcrumb items from path
|
||||
@@ -26,47 +28,31 @@
|
||||
* @returns {Array} Array of breadcrumb items
|
||||
*/
|
||||
function getBreadcrumbs(pathname, maxVisible = 3) {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const allItems = [
|
||||
{ label: 'Home', path: '/' }
|
||||
];
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const allItems = [{ label: "Home", path: "/" }];
|
||||
|
||||
let currentPath = '';
|
||||
let currentPath = "";
|
||||
segments.forEach((segment, index) => {
|
||||
currentPath += `/${segment}`;
|
||||
// Convert segment to readable label
|
||||
const label = formatBreadcrumbLabel(segment);
|
||||
allItems.push({
|
||||
label,
|
||||
path: currentPath,
|
||||
isLast: index === segments.length - 1
|
||||
isLast: index === segments.length - 1,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle truncation if too many items
|
||||
// If we have more than maxVisible items, we truncate the middle ones
|
||||
// Always show Home (first) and Current (last)
|
||||
if (allItems.length > maxVisible) {
|
||||
const firstItem = allItems[0];
|
||||
const lastItem = allItems[allItems.length - 1];
|
||||
|
||||
// Calculate how many items we can show in the middle
|
||||
// We reserve 1 for first, 1 for last, and 1 for ellipsis
|
||||
// But ellipsis isn't a real item in terms of logic, it just replaces hidden ones
|
||||
// Actually, let's keep it simple: First ... [Last - (maxVisible - 2) .. Last]
|
||||
|
||||
const itemsToShow = [];
|
||||
itemsToShow.push(firstItem);
|
||||
itemsToShow.push({ isEllipsis: true });
|
||||
|
||||
// Add the last (maxVisible - 2) items
|
||||
// e.g. if maxVisible is 3, we show Start ... End
|
||||
// if maxVisible is 4, we show Start ... SecondLast End
|
||||
const startFromIndex = allItems.length - (maxVisible - 1);
|
||||
for(let i = startFromIndex; i < allItems.length; i++) {
|
||||
itemsToShow.push(allItems[i]);
|
||||
}
|
||||
return itemsToShow;
|
||||
const firstItem = allItems[0];
|
||||
const itemsToShow = [];
|
||||
itemsToShow.push(firstItem);
|
||||
itemsToShow.push({ isEllipsis: true });
|
||||
|
||||
const startFromIndex = allItems.length - (maxVisible - 1);
|
||||
for (let i = startFromIndex; i < allItems.length; i++) {
|
||||
itemsToShow.push(allItems[i]);
|
||||
}
|
||||
return itemsToShow;
|
||||
}
|
||||
|
||||
return allItems;
|
||||
@@ -78,63 +64,46 @@
|
||||
* @returns {string} Formatted label
|
||||
*/
|
||||
function formatBreadcrumbLabel(segment) {
|
||||
// Handle special cases
|
||||
const specialCases = {
|
||||
'dashboards': 'nav.dashboard',
|
||||
'datasets': 'nav.tools_mapper',
|
||||
'storage': 'nav.tools_storage',
|
||||
'admin': 'nav.admin',
|
||||
'settings': 'nav.settings',
|
||||
'git': 'nav.git'
|
||||
};
|
||||
dashboards: "nav.dashboard",
|
||||
datasets: "nav.tools_mapper",
|
||||
storage: "nav.tools_storage",
|
||||
admin: "nav.admin",
|
||||
settings: "nav.settings",
|
||||
git: "nav.git",
|
||||
};
|
||||
|
||||
if (specialCases[segment]) {
|
||||
return _(specialCases[segment]) || segment;
|
||||
}
|
||||
|
||||
// Default: capitalize and replace hyphens with spaces
|
||||
return segment
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.breadcrumbs {
|
||||
@apply flex items-center space-x-2 text-sm text-gray-600;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
@apply hover:text-blue-600 hover:underline cursor-pointer transition-colors;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
@apply text-gray-900 font-medium;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb navigation">
|
||||
<nav
|
||||
class="flex items-center space-x-2 text-sm text-gray-600"
|
||||
aria-label="Breadcrumb navigation"
|
||||
>
|
||||
{#each breadcrumbItems as item, index}
|
||||
<div class="breadcrumb-item">
|
||||
<div class="flex items-center">
|
||||
{#if item.isEllipsis}
|
||||
<span class="breadcrumb-separator">...</span>
|
||||
<span class="text-gray-400">...</span>
|
||||
{:else if item.isLast}
|
||||
<span class="breadcrumb-current">{item.label}</span>
|
||||
<span class="text-gray-900 font-medium">{item.label}</span>
|
||||
{:else}
|
||||
<a href={item.path} class="breadcrumb-link">{item.label}</a>
|
||||
<a
|
||||
href={item.path}
|
||||
class="hover:text-primary hover:underline cursor-pointer transition-colors"
|
||||
>{item.label}</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if index < breadcrumbItems.length - 1}
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span class="text-gray-400">/</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
{
|
||||
id: "dashboards",
|
||||
label: $t.nav?.dashboards || "DASHBOARDS",
|
||||
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z", // Grid icon
|
||||
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
|
||||
path: "/dashboards",
|
||||
subItems: [
|
||||
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
|
||||
@@ -39,7 +39,7 @@
|
||||
{
|
||||
id: "datasets",
|
||||
label: $t.nav?.datasets || "DATASETS",
|
||||
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z", // List icon
|
||||
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
|
||||
path: "/datasets",
|
||||
subItems: [
|
||||
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
|
||||
@@ -48,7 +48,7 @@
|
||||
{
|
||||
id: "storage",
|
||||
label: $t.nav?.storage || "STORAGE",
|
||||
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z", // Folder icon
|
||||
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
|
||||
path: "/storage",
|
||||
subItems: [
|
||||
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
|
||||
@@ -61,7 +61,7 @@
|
||||
{
|
||||
id: "admin",
|
||||
label: $t.nav?.admin || "ADMIN",
|
||||
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z", // User icon
|
||||
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
|
||||
path: "/admin",
|
||||
subItems: [
|
||||
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
|
||||
@@ -75,7 +75,7 @@
|
||||
let activeCategory = "dashboards";
|
||||
let activeItem = "/dashboards";
|
||||
let isMobileOpen = false;
|
||||
let expandedCategories = new Set(["dashboards"]); // Track expanded categories
|
||||
let expandedCategories = new Set(["dashboards"]);
|
||||
|
||||
// Subscribe to sidebar store
|
||||
$: if ($sidebarStore) {
|
||||
@@ -90,7 +90,7 @@
|
||||
{
|
||||
id: "dashboards",
|
||||
label: $t.nav?.dashboards || "DASHBOARDS",
|
||||
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z", // Grid icon
|
||||
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
|
||||
path: "/dashboards",
|
||||
subItems: [
|
||||
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
|
||||
@@ -99,7 +99,7 @@
|
||||
{
|
||||
id: "datasets",
|
||||
label: $t.nav?.datasets || "DATASETS",
|
||||
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z", // List icon
|
||||
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
|
||||
path: "/datasets",
|
||||
subItems: [
|
||||
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
|
||||
@@ -108,7 +108,7 @@
|
||||
{
|
||||
id: "storage",
|
||||
label: $t.nav?.storage || "STORAGE",
|
||||
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z", // Folder icon
|
||||
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
|
||||
path: "/storage",
|
||||
subItems: [
|
||||
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
|
||||
@@ -121,7 +121,7 @@
|
||||
{
|
||||
id: "admin",
|
||||
label: $t.nav?.admin || "ADMIN",
|
||||
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z", // User icon
|
||||
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
|
||||
path: "/admin",
|
||||
subItems: [
|
||||
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
|
||||
@@ -133,7 +133,6 @@
|
||||
|
||||
// Update active item when page changes
|
||||
$: if ($page && $page.url.pathname !== activeItem) {
|
||||
// Find matching category
|
||||
const matched = categories.find((cat) =>
|
||||
$page.url.pathname.startsWith(cat.path),
|
||||
);
|
||||
@@ -143,7 +142,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click on sidebar item
|
||||
function handleItemClick(category) {
|
||||
console.log(`[Sidebar][Action] Clicked category ${category.id}`);
|
||||
setActiveItem(category.id, category.path);
|
||||
@@ -153,7 +151,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click on category header to toggle expansion
|
||||
function handleCategoryToggle(categoryId, event) {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -173,28 +170,24 @@
|
||||
} else {
|
||||
expandedCategories.add(categoryId);
|
||||
}
|
||||
expandedCategories = expandedCategories; // Trigger reactivity
|
||||
expandedCategories = expandedCategories;
|
||||
}
|
||||
|
||||
// Handle click on sub-item
|
||||
function handleSubItemClick(categoryId, path) {
|
||||
console.log(`[Sidebar][Action] Clicked sub-item ${path}`);
|
||||
setActiveItem(categoryId, path);
|
||||
closeMobile();
|
||||
// Force navigation if it's a link
|
||||
if (browser) {
|
||||
window.location.href = path;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle toggle button click
|
||||
function handleToggleClick(event) {
|
||||
event.stopPropagation();
|
||||
console.log("[Sidebar][Action] Toggle sidebar");
|
||||
toggleSidebar();
|
||||
}
|
||||
|
||||
// Handle mobile overlay click
|
||||
function handleOverlayClick() {
|
||||
console.log("[Sidebar][Action] Close mobile overlay");
|
||||
closeMobile();
|
||||
@@ -209,7 +202,7 @@
|
||||
<!-- Mobile overlay (only on mobile) -->
|
||||
{#if isMobileOpen}
|
||||
<div
|
||||
class="mobile-overlay"
|
||||
class="fixed inset-0 bg-black/50 z-20 md:hidden"
|
||||
on:click={handleOverlayClick}
|
||||
on:keydown={(e) => e.key === "Escape" && handleOverlayClick()}
|
||||
role="presentation"
|
||||
@@ -218,12 +211,18 @@
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div
|
||||
class="sidebar {isExpanded ? 'expanded' : 'collapsed'} {isMobileOpen
|
||||
? 'mobile'
|
||||
: 'mobile-hidden'}"
|
||||
class="bg-white border-r border-gray-200 flex flex-col h-screen fixed left-0 top-0 z-30 transition-[width] duration-200 ease-in-out
|
||||
{isExpanded ? 'w-sidebar' : 'w-sidebar-collapsed'}
|
||||
{isMobileOpen
|
||||
? 'translate-x-0 w-sidebar'
|
||||
: '-translate-x-full md:translate-x-0'}"
|
||||
>
|
||||
<!-- Header (simplified, toggle moved to footer) -->
|
||||
<div class="sidebar-header {isExpanded ? '' : 'collapsed'}">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center p-4 border-b border-gray-200 {isExpanded
|
||||
? 'justify-between'
|
||||
: 'justify-center'}"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<span class="font-semibold text-gray-800">Menu</span>
|
||||
{:else}
|
||||
@@ -232,13 +231,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Navigation items -->
|
||||
<nav class="nav-section">
|
||||
<nav class="flex-1 overflow-y-auto py-2">
|
||||
{#each categories as category}
|
||||
<div class="category">
|
||||
<div>
|
||||
<!-- Category Header -->
|
||||
<div
|
||||
class="category-header {activeCategory === category.id
|
||||
? 'active'
|
||||
class="flex items-center justify-between px-4 py-3 cursor-pointer transition-colors hover:bg-gray-100
|
||||
{activeCategory === category.id
|
||||
? 'bg-primary-light text-primary md:border-r-2 md:border-primary'
|
||||
: ''}"
|
||||
on:click={(e) => handleCategoryToggle(category.id, e)}
|
||||
on:keydown={(e) =>
|
||||
@@ -251,7 +251,7 @@
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
class="nav-icon"
|
||||
class="w-5 h-5 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -261,13 +261,17 @@
|
||||
<path d={category.icon} />
|
||||
</svg>
|
||||
{#if isExpanded}
|
||||
<span class="nav-label">{category.label}</span>
|
||||
<span class="ml-3 text-sm font-medium truncate"
|
||||
>{category.label}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isExpanded}
|
||||
<svg
|
||||
class="category-toggle {expandedCategories.has(category.id)
|
||||
? 'expanded'
|
||||
class="text-gray-400 transition-transform duration-200 {expandedCategories.has(
|
||||
category.id,
|
||||
)
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@@ -284,10 +288,13 @@
|
||||
|
||||
<!-- Sub Items (only when expanded) -->
|
||||
{#if isExpanded && expandedCategories.has(category.id)}
|
||||
<div class="sub-items">
|
||||
<div class="bg-gray-50 overflow-hidden transition-all duration-200">
|
||||
{#each category.subItems as subItem}
|
||||
<div
|
||||
class="sub-item {activeItem === subItem.path ? 'active' : ''}"
|
||||
class="flex items-center px-4 py-2 pl-12 cursor-pointer transition-colors text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900
|
||||
{activeItem === subItem.path
|
||||
? 'bg-primary-light text-primary'
|
||||
: ''}"
|
||||
on:click={() => handleSubItemClick(category.id, subItem.path)}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") &&
|
||||
@@ -306,8 +313,11 @@
|
||||
|
||||
<!-- Footer with Collapse button -->
|
||||
{#if isExpanded}
|
||||
<div class="sidebar-footer">
|
||||
<button class="collapse-btn" on:click={handleToggleClick}>
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<button
|
||||
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
on:click={handleToggleClick}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@@ -324,8 +334,12 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sidebar-footer">
|
||||
<button class="collapse-btn" on:click={handleToggleClick} aria-label="Expand sidebar">
|
||||
<div class="border-t border-gray-200 p-4">
|
||||
<button
|
||||
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
on:click={handleToggleClick}
|
||||
aria-label="Expand sidebar"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@@ -337,101 +351,10 @@
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
<span class="collapse-btn-text">Expand</span>
|
||||
<span class="ml-2">Expand</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:Sidebar:Component] -->
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
@apply bg-white border-r border-gray-200 flex flex-col h-screen fixed left-0 top-0 z-30;
|
||||
transition: width 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.sidebar.expanded {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidebar.mobile {
|
||||
@apply translate-x-0;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.sidebar.mobile-hidden {
|
||||
@apply -translate-x-full md:translate-x-0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@apply flex items-center justify-between p-4 border-b border-gray-200;
|
||||
}
|
||||
|
||||
.sidebar-header.collapsed {
|
||||
@apply justify-center;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
@apply w-5 h-5 flex-shrink-0;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
@apply ml-3 text-sm font-medium truncate;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
@apply flex items-center justify-between px-4 py-3 cursor-pointer transition-colors hover:bg-gray-100;
|
||||
}
|
||||
|
||||
.category-header.active {
|
||||
@apply bg-blue-50 text-blue-600 md:border-r-2 md:border-blue-600;
|
||||
}
|
||||
|
||||
.category-toggle {
|
||||
@apply text-gray-400 transition-transform duration-200;
|
||||
}
|
||||
|
||||
.category-toggle.expanded {
|
||||
@apply rotate-180;
|
||||
}
|
||||
|
||||
.sub-items {
|
||||
@apply bg-gray-50 overflow-hidden transition-all duration-200;
|
||||
}
|
||||
|
||||
.sub-item {
|
||||
@apply flex items-center px-4 py-2 pl-12 cursor-pointer transition-colors text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900;
|
||||
}
|
||||
|
||||
.sub-item.active {
|
||||
@apply bg-blue-50 text-blue-600;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
@apply border-t border-gray-200 p-4;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
@apply flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
.collapse-btn-text {
|
||||
@apply ml-2;
|
||||
}
|
||||
|
||||
/* Mobile overlay */
|
||||
.mobile-overlay {
|
||||
@apply fixed inset-0 bg-black bg-opacity-50 z-20;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mobile-overlay {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,54 +31,38 @@
|
||||
let showUserMenu = false;
|
||||
let isSearchFocused = false;
|
||||
|
||||
// Subscribe to sidebar store for responsive layout
|
||||
$: isExpanded = $sidebarStore?.isExpanded ?? true;
|
||||
|
||||
// Subscribe to activity store
|
||||
$: activeCount = $activityStore?.activeCount || 0;
|
||||
$: recentTasks = $activityStore?.recentTasks || [];
|
||||
|
||||
// Get user from auth store
|
||||
$: user = $auth?.user || null;
|
||||
|
||||
// Toggle user menu
|
||||
function toggleUserMenu(event) {
|
||||
event.stopPropagation();
|
||||
showUserMenu = !showUserMenu;
|
||||
console.log(`[TopNavbar][Action] Toggle user menu: ${showUserMenu}`);
|
||||
}
|
||||
|
||||
// Close user menu
|
||||
function closeUserMenu() {
|
||||
showUserMenu = false;
|
||||
}
|
||||
|
||||
// Handle logout
|
||||
function handleLogout() {
|
||||
console.log("[TopNavbar][Action] Logout");
|
||||
auth.logout();
|
||||
closeUserMenu();
|
||||
// Navigate to login
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
// Handle activity indicator click - open Task Drawer with most recent task
|
||||
function handleActivityClick() {
|
||||
console.log("[TopNavbar][Action] Activity indicator clicked");
|
||||
// Open drawer with the most recent running task, or list mode
|
||||
const runningTask = recentTasks.find((t) => t.status === "RUNNING");
|
||||
if (runningTask) {
|
||||
openDrawerForTask(runningTask.taskId);
|
||||
} else if (recentTasks.length > 0) {
|
||||
openDrawerForTask(recentTasks[recentTasks.length - 1].taskId);
|
||||
} else {
|
||||
// No tracked tasks — open in list mode to show recent tasks from API
|
||||
openDrawer();
|
||||
}
|
||||
dispatch("activityClick");
|
||||
}
|
||||
|
||||
// Handle search focus
|
||||
function handleSearchFocus() {
|
||||
isSearchFocused = true;
|
||||
}
|
||||
@@ -87,34 +71,31 @@
|
||||
isSearchFocused = false;
|
||||
}
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
function handleDocumentClick(event) {
|
||||
if (!event.target.closest(".user-menu-container")) {
|
||||
closeUserMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for document clicks
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
}
|
||||
|
||||
// Handle hamburger menu click for mobile
|
||||
function handleHamburgerClick(event) {
|
||||
event.stopPropagation();
|
||||
console.log("[TopNavbar][Action] Toggle mobile sidebar");
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="navbar {isExpanded ? 'with-sidebar' : 'with-collapsed-sidebar'} mobile"
|
||||
class="bg-white border-b border-gray-200 fixed top-0 right-0 left-0 h-16 flex items-center justify-between px-4 z-40
|
||||
{isExpanded ? 'md:left-[240px]' : 'md:left-16'}"
|
||||
>
|
||||
<!-- Left section: Hamburger (mobile) + Logo -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Hamburger Menu (mobile only) -->
|
||||
<button
|
||||
class="hamburger-btn"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 text-gray-600 md:hidden"
|
||||
on:click={handleHamburgerClick}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
@@ -134,9 +115,12 @@
|
||||
</button>
|
||||
|
||||
<!-- Logo/Brand -->
|
||||
<a href="/" class="logo-link">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center text-xl font-bold text-gray-800 hover:text-primary transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="logo-icon"
|
||||
class="w-8 h-8 mr-2 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
@@ -148,10 +132,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Search placeholder (non-functional for now) -->
|
||||
<div class="search-container">
|
||||
<div class="flex-1 max-w-xl mx-4 hidden md:block">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input {isSearchFocused ? 'focused' : ''}"
|
||||
class="w-full px-4 py-2 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
|
||||
{isSearchFocused ? 'bg-white border border-primary-ring' : ''}"
|
||||
placeholder={$t.common.search || "Search..."}
|
||||
on:focus={handleSearchFocus}
|
||||
on:blur={handleSearchBlur}
|
||||
@@ -159,10 +144,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Nav Actions -->
|
||||
<div class="nav-actions">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Activity Indicator -->
|
||||
<div
|
||||
class="activity-indicator"
|
||||
class="relative cursor-pointer p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
on:click={handleActivityClick}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && handleActivityClick()}
|
||||
@@ -171,7 +156,7 @@
|
||||
aria-label="Activity"
|
||||
>
|
||||
<svg
|
||||
class="activity-icon"
|
||||
class="w-6 h-6 text-gray-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -183,14 +168,17 @@
|
||||
/>
|
||||
</svg>
|
||||
{#if activeCount > 0}
|
||||
<span class="activity-badge">{activeCount}</span>
|
||||
<span
|
||||
class="absolute -top-1 -right-1 bg-destructive text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
|
||||
>{activeCount}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="user-menu-container">
|
||||
<div class="user-menu-container relative">
|
||||
<div
|
||||
class="user-avatar"
|
||||
class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center cursor-pointer hover:bg-primary-hover transition-colors"
|
||||
on:click={toggleUserMenu}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && toggleUserMenu(e)}
|
||||
@@ -208,13 +196,17 @@
|
||||
</div>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div class="user-dropdown {showUserMenu ? '' : 'hidden'}">
|
||||
<div class="dropdown-item">
|
||||
<div
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 {showUserMenu
|
||||
? ''
|
||||
: 'hidden'}"
|
||||
>
|
||||
<div class="px-4 py-2 text-sm text-gray-700">
|
||||
<strong>{user?.username || "User"}</strong>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<div class="border-t border-gray-200 my-1"></div>
|
||||
<div
|
||||
class="dropdown-item"
|
||||
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
|
||||
on:click={() => {
|
||||
window.location.href = "/settings";
|
||||
}}
|
||||
@@ -227,7 +219,7 @@
|
||||
{$t.nav?.settings || "Settings"}
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-item danger"
|
||||
class="px-4 py-2 text-sm text-destructive hover:bg-destructive-light cursor-pointer"
|
||||
on:click={handleLogout}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && handleLogout()}
|
||||
@@ -242,96 +234,3 @@
|
||||
</nav>
|
||||
|
||||
<!-- [/DEF:TopNavbar:Component] -->
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
@apply bg-white border-b border-gray-200 fixed top-0 right-0 left-0 h-16 flex items-center justify-between px-4 z-40;
|
||||
}
|
||||
|
||||
.navbar.with-sidebar {
|
||||
@apply md:left-64;
|
||||
}
|
||||
|
||||
.navbar.with-collapsed-sidebar {
|
||||
@apply md:left-16;
|
||||
}
|
||||
|
||||
.navbar.mobile {
|
||||
@apply left-0;
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
@apply flex items-center text-xl font-bold text-gray-800 hover:text-blue-600 transition-colors;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
@apply w-8 h-8 mr-2 text-blue-600;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
@apply flex-1 max-w-xl mx-4;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@apply w-full px-4 py-2 bg-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all;
|
||||
}
|
||||
|
||||
.search-input.focused {
|
||||
@apply bg-white border border-blue-500;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
@apply flex items-center space-x-4;
|
||||
}
|
||||
|
||||
.hamburger-btn {
|
||||
@apply p-2 rounded-lg hover:bg-gray-100 text-gray-600 md:hidden;
|
||||
}
|
||||
|
||||
.activity-indicator {
|
||||
@apply relative cursor-pointer p-2 rounded-lg hover:bg-gray-100 transition-colors;
|
||||
}
|
||||
|
||||
.activity-badge {
|
||||
@apply absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
@apply w-6 h-6 text-gray-600;
|
||||
}
|
||||
|
||||
.user-menu-container {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
@apply w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center cursor-pointer hover:bg-blue-700 transition-colors;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
@apply absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50;
|
||||
}
|
||||
|
||||
.user-dropdown.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@apply px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer;
|
||||
}
|
||||
|
||||
.dropdown-item.danger {
|
||||
@apply text-red-600 hover:bg-red-50;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
@apply border-t border-gray-200 my-1;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.search-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,45 +9,51 @@
|
||||
@INVARIANT: Supports accessible labels and keyboard navigation.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { cn } from '$lib/utils.js';
|
||||
// [/SECTION: IMPORTS]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
/**
|
||||
* @purpose Define component interface and default values.
|
||||
* @purpose Define component interface and default values (Svelte 5 Runes).
|
||||
*/
|
||||
export let variant: 'primary' | 'secondary' | 'danger' | 'ghost' = 'primary';
|
||||
export let size: 'sm' | 'md' | 'lg' = 'md';
|
||||
export let isLoading: boolean = false;
|
||||
export let disabled: boolean = false;
|
||||
export let type: 'button' | 'submit' | 'reset' = 'button';
|
||||
let className: string = "";
|
||||
export { className as class };
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
class: className = '',
|
||||
children,
|
||||
onclick,
|
||||
...rest
|
||||
} = $props();
|
||||
// [/SECTION: PROPS]
|
||||
|
||||
const baseStyles = "inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 rounded-md";
|
||||
|
||||
const baseStyles = 'inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 rounded-md';
|
||||
|
||||
const variants = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500",
|
||||
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-500",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500",
|
||||
ghost: "bg-transparent hover:bg-gray-100 text-gray-700 focus-visible:ring-gray-500"
|
||||
primary: 'bg-primary text-white hover:bg-primary-hover focus-visible:ring-primary-ring',
|
||||
secondary: 'bg-secondary text-secondary-text hover:bg-secondary-hover focus-visible:ring-secondary-ring',
|
||||
danger: 'bg-destructive text-white hover:bg-destructive-hover focus-visible:ring-destructive-ring',
|
||||
ghost: 'bg-transparent hover:bg-ghost-hover text-ghost-text focus-visible:ring-ghost-ring',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
md: "h-10 px-4 py-2 text-sm",
|
||||
lg: "h-12 px-6 text-base"
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
md: 'h-10 px-4 py-2 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<button
|
||||
{type}
|
||||
class="{baseStyles} {variants[variant]} {sizes[size]} {className}"
|
||||
class={cn(baseStyles, variants[variant], sizes[size], className)}
|
||||
disabled={disabled || isLoading}
|
||||
on:click
|
||||
{onclick}
|
||||
{...rest}
|
||||
>
|
||||
{#if isLoading}
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@@ -55,7 +61,7 @@
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
</button>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
|
||||
@@ -6,31 +6,46 @@
|
||||
@LAYER: Atom
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { cn } from "$lib/utils.js";
|
||||
// [/SECTION: IMPORTS]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let title: string = "";
|
||||
export let padding: 'none' | 'sm' | 'md' | 'lg' = 'md';
|
||||
let {
|
||||
title = "",
|
||||
padding = "md",
|
||||
class: className = "",
|
||||
children,
|
||||
...rest
|
||||
} = $props();
|
||||
// [/SECTION: PROPS]
|
||||
|
||||
const paddings = {
|
||||
none: "p-0",
|
||||
sm: "p-3",
|
||||
md: "p-6",
|
||||
lg: "p-8"
|
||||
lg: "p-8",
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white text-gray-950 shadow-sm">
|
||||
<div
|
||||
class={cn(
|
||||
"rounded-lg border border-gray-200 bg-white text-gray-950 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{#if title}
|
||||
<div class="flex flex-col space-y-1.5 p-6 border-b border-gray-100">
|
||||
<h3 class="text-lg font-semibold leading-none tracking-tight">{title}</h3>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="{paddings[padding]}">
|
||||
<slot />
|
||||
<div class={paddings[padding]}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
<!-- [/DEF:Card:Component] -->
|
||||
<!-- [/DEF:Card:Component] -->
|
||||
|
||||
@@ -8,14 +8,22 @@
|
||||
@INVARIANT: Consistent spacing and focus states.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { cn } from "$lib/utils.js";
|
||||
// [/SECTION: IMPORTS]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let label: string = "";
|
||||
export let value: string = "";
|
||||
export let placeholder: string = "";
|
||||
export let error: string = "";
|
||||
export let disabled: boolean = false;
|
||||
export let type: 'text' | 'password' | 'email' | 'number' = 'text';
|
||||
let {
|
||||
label = "",
|
||||
value = $bindable(""),
|
||||
placeholder = "",
|
||||
error = "",
|
||||
disabled = false,
|
||||
type = "text",
|
||||
class: className = "",
|
||||
...rest
|
||||
} = $props();
|
||||
// [/SECTION: PROPS]
|
||||
|
||||
let id = "input-" + Math.random().toString(36).substr(2, 9);
|
||||
@@ -28,20 +36,25 @@
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
|
||||
<input
|
||||
{id}
|
||||
{type}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
bind:value
|
||||
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {error ? 'border-red-500' : ''}"
|
||||
class={cn(
|
||||
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
error ? "border-destructive" : "",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<span class="text-xs text-red-500">{error}</span>
|
||||
<span class="text-xs text-destructive">{error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
<!-- [/DEF:Input:Component] -->
|
||||
<!-- [/DEF:Input:Component] -->
|
||||
|
||||
@@ -7,25 +7,22 @@
|
||||
@RELATION: BINDS_TO -> i18n.locale
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { locale } from '$lib/i18n';
|
||||
import Select from './Select.svelte';
|
||||
import { locale } from "$lib/i18n";
|
||||
import Select from "./Select.svelte";
|
||||
// [/SECTION: IMPORTS]
|
||||
|
||||
const options = [
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'en', label: 'English' }
|
||||
{ value: "ru", label: "Русский" },
|
||||
{ value: "en", label: "English" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<div class="w-32">
|
||||
<Select
|
||||
bind:value={$locale}
|
||||
{options}
|
||||
/>
|
||||
<Select bind:value={$locale} {options} />
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
<!-- [/DEF:LanguageSwitcher:Component] -->
|
||||
<!-- [/DEF:LanguageSwitcher:Component] -->
|
||||
|
||||
@@ -6,22 +6,35 @@
|
||||
@LAYER: Atom
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { cn } from "$lib/utils.js";
|
||||
// [/SECTION: IMPORTS]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let title: string = "";
|
||||
let {
|
||||
title = "",
|
||||
class: className = "",
|
||||
subtitle,
|
||||
actions,
|
||||
...rest
|
||||
} = $props();
|
||||
// [/SECTION: PROPS]
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
<header class="flex items-center justify-between mb-8">
|
||||
<header
|
||||
class={cn("flex items-center justify-between mb-8", className)}
|
||||
{...rest}
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-gray-900">{title}</h1>
|
||||
<slot name="subtitle" />
|
||||
{@render subtitle?.()}
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<slot name="actions" />
|
||||
{@render actions?.()}
|
||||
</div>
|
||||
</header>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
<!-- [/DEF:PageHeader:Component] -->
|
||||
<!-- [/DEF:PageHeader:Component] -->
|
||||
|
||||
@@ -6,12 +6,20 @@
|
||||
@LAYER: Atom
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { cn } from "$lib/utils.js";
|
||||
// [/SECTION: IMPORTS]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
export let label: string = "";
|
||||
export let value: string | number = "";
|
||||
export let options: Array<{ value: string | number, label: string }> = [];
|
||||
export let disabled: boolean = false;
|
||||
let {
|
||||
label = "",
|
||||
value = $bindable(""),
|
||||
options = [],
|
||||
disabled = false,
|
||||
class: className = "",
|
||||
...rest
|
||||
} = $props();
|
||||
// [/SECTION: PROPS]
|
||||
|
||||
let id = "select-" + Math.random().toString(36).substr(2, 9);
|
||||
@@ -29,7 +37,11 @@
|
||||
{id}
|
||||
{disabled}
|
||||
bind:value
|
||||
class="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class={cn(
|
||||
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{#each options as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
@@ -38,4 +50,4 @@
|
||||
</div>
|
||||
<!-- [/SECTION: TEMPLATE] -->
|
||||
|
||||
<!-- [/DEF:Select:Component] -->
|
||||
<!-- [/DEF:Select:Component] -->
|
||||
|
||||
8
frontend/src/lib/utils.js
Normal file
8
frontend/src/lib/utils.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Merges class names into a single string.
|
||||
* @param {...(string | undefined | null | false)} inputs
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cn(...inputs) {
|
||||
return inputs.filter(Boolean).join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user