refactor: Kopfnavigation auf bits-uis NavigationMenu
Das Team-Submenue stand auf einem DropdownMenu, und alles, was ein
Menuepunkt braucht und ein Dropdown nicht kann, stand hier als
Handarbeit daneben: ein Zeitgeber fuers Aufklappen, ueberschriebene
Zeigerhandler gegen das Flackern beim Klick, unterdrueckter Fokus gegen
den Ring nach dem Zeigerbesuch.
NavigationMenu kann das von sich aus. Es hat openOnHover samt
delayDuration, es haelt seinen eigenen onclick zurueck, wenn per Zeiger
aufgeklappt wurde ("if opened via pointer move, we prevent the click
event"), es faengt keinen Fokus und sperrt kein Scrollen. Der Trigger
bleibt ueber das child-Snippet ein Link auf die Teamseite.
viewport={false}: Das Panel haengt unter seinem Menuepunkt, statt als
animierte Flaeche ueber die ganze Leiste zu fahren. Bei einem einzigen
aufklappbaren Punkt waere das grosses Kino ohne Stueck.
Das mobile Menue bekommt die Teams stattdessen eingerueckt und
aufgeklappt untergestellt. Auf dem Touchscreen gibt es kein
Ueberfahren — mit dem Menue allein waere seit dem Wegfall der Liste
"Meine Teams" gar kein Wechsel mehr moeglich gewesen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
This commit is contained in:
parent
0f4b532773
commit
65bb7f1f3f
13 changed files with 344 additions and 125 deletions
21
frontend/package-lock.json
generated
21
frontend/package-lock.json
generated
|
|
@ -18,7 +18,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@internationalized/date": "^3.12.1",
|
"@internationalized/date": "^3.12.1",
|
||||||
"@lucide/svelte": "^1.16.0",
|
"@lucide/svelte": "^1.42.0",
|
||||||
"@sveltejs/adapter-node": "^5.5.7",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@sveltejs/kit": "^2.43.2",
|
"@sveltejs/kit": "^2.43.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
"svelte": "^5.39.5",
|
"svelte": "^5.39.5",
|
||||||
"svelte-check": "^4.3.2",
|
"svelte-check": "^4.3.2",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
|
|
@ -812,9 +812,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lucide/svelte": {
|
"node_modules/@lucide/svelte": {
|
||||||
"version": "1.16.0",
|
"version": "1.42.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.42.0.tgz",
|
||||||
"integrity": "sha512-AvvPJnaWxeiNkAljI5MsSEc84yHPLMaWQIAJOcbX7k9au/f9ITS7cxTTQiautDiOFKVOXiYdZ+d6mtl88J+Kbg==",
|
"integrity": "sha512-9vOi3EdzJht5aD7F5OraoxrK2GyyL1Y+kVXEtIg4+IAdKmDBrwYK2d/x7akC89uA+cvkfjcw+IuzH0vfd/TuzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|
@ -5737,13 +5737,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwind-variants": {
|
"node_modules/tailwind-variants": {
|
||||||
"version": "3.2.2",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz",
|
||||||
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
|
"integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.x",
|
"node": ">=16.9.x",
|
||||||
"pnpm": ">=7.x"
|
"pnpm": ">=7.x"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|
@ -5753,6 +5753,9 @@
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"tailwind-merge": {
|
"tailwind-merge": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"tailwindcss": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@internationalized/date": "^3.12.1",
|
"@internationalized/date": "^3.12.1",
|
||||||
"@lucide/svelte": "^1.16.0",
|
"@lucide/svelte": "^1.42.0",
|
||||||
"@sveltejs/adapter-node": "^5.5.7",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@sveltejs/kit": "^2.43.2",
|
"@sveltejs/kit": "^2.43.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
"svelte": "^5.39.5",
|
"svelte": "^5.39.5",
|
||||||
"svelte-check": "^4.3.2",
|
"svelte-check": "^4.3.2",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,28 @@
|
||||||
* Ueberfahren aufklappt: darunter der Wechsel zwischen den eigenen Teams
|
* Ueberfahren aufklappt: darunter der Wechsel zwischen den eigenen Teams
|
||||||
* und der Weg zu einem neuen.
|
* und der Weg zu einem neuen.
|
||||||
*
|
*
|
||||||
* Der Punkt selbst ist ein Link auf die Teamseite. Ein eigener Eintrag
|
* Traegt bits-uis NavigationMenu und nicht das DropdownMenu. Ein Dropdown
|
||||||
* „Uebersicht" waere die zweite Tuer zu derselben Seite, und eine
|
* ist ein Menue, das auf Ansage aufgeht; eine Navigationsleiste ist etwas
|
||||||
* Ueberschrift ueber drei Teamnamen sagt nichts, was die Namen nicht
|
* anderes, und die Unterschiede sind genau die, die hier gebraucht werden:
|
||||||
* schon sagen.
|
* Aufklappen beim Ueberfahren samt Verzoegerung, ein Klick, der nach dem
|
||||||
|
* Hover-Aufklappen nicht wieder zuklappt, kein Fokusfang, keine
|
||||||
|
* Scrollsperre, kein Fokusring nach dem Zeigerbesuch. Das alles stand hier
|
||||||
|
* vorher als Handarbeit — Zeitgeber, ueberschriebene Zeigerhandler,
|
||||||
|
* unterdrueckter Fokus. Die Komponente kann es von sich aus.
|
||||||
|
*
|
||||||
|
* Der Punkt selbst bleibt ein Link auf die Teamseite: Der Trigger wird
|
||||||
|
* ueber das child-Snippet als <a> gezeichnet. bits-ui haelt seinen eigenen
|
||||||
|
* onclick zurueck, wenn per Zeiger aufgeklappt wurde („if opened via
|
||||||
|
* pointer move, we prevent the click event"), der Klick landet also beim
|
||||||
|
* Link.
|
||||||
*
|
*
|
||||||
* Auf dem Touchscreen gibt es kein Ueberfahren: Dort fuehrt der Tipp auf
|
* Auf dem Touchscreen gibt es kein Ueberfahren: Dort fuehrt der Tipp auf
|
||||||
* die Teamseite, und gewechselt wird in deren Liste „Meine Teams".
|
* die Teamseite, und gewechselt wird in deren Liste.
|
||||||
*/
|
*/
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { getTeamContext } from '$lib/stores/teams.svelte'
|
import { getTeamContext } from '$lib/stores/teams.svelte'
|
||||||
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
|
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
|
||||||
import * as DropdownMenu from '@/components/ui/dropdown-menu'
|
import * as NavigationMenu from '@/components/ui/navigation-menu'
|
||||||
import { ChevronDown, Plus, Users } from 'lucide-svelte'
|
import { ChevronDown, Plus, Users } from 'lucide-svelte'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -26,21 +36,6 @@
|
||||||
const teams = getTeamContext()
|
const teams = getTeamContext()
|
||||||
|
|
||||||
let createDialog = $state(false)
|
let createDialog = $state(false)
|
||||||
let menuOpen = $state(false)
|
|
||||||
|
|
||||||
// Zwischen Knopf und aufgeklapptem Menue liegt ein Spalt. Ohne die kurze
|
|
||||||
// Frist fiele das Menue zu, sobald der Zeiger ihn ueberquert.
|
|
||||||
let closeTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
function enter() {
|
|
||||||
if (closeTimer) clearTimeout(closeTimer)
|
|
||||||
menuOpen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function leave() {
|
|
||||||
if (closeTimer) clearTimeout(closeTimer)
|
|
||||||
closeTimer = setTimeout(() => (menuOpen = false), 150)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Team wechseln heisst hier auch: hinsehen. Der Wechsel allein aendert
|
* Team wechseln heisst hier auch: hinsehen. Der Wechsel allein aendert
|
||||||
|
|
@ -52,86 +47,49 @@
|
||||||
goto('/dashboard/team')
|
goto('/dashboard/team')
|
||||||
onnavigate?.()
|
onnavigate?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemClass =
|
||||||
|
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-left transition-colors hover:bg-accent'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DropdownMenu.Root bind:open={menuOpen}>
|
<NavigationMenu.Item value="team">
|
||||||
<DropdownMenu.Trigger>
|
<NavigationMenu.Trigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<!--
|
<a {...props} href="/dashboard/team" class={className} onclick={() => onnavigate?.()}>
|
||||||
Die Zeiger- und Tastenhandler von bits-ui werden bewusst
|
|
||||||
ueberschrieben: Deren onclick ruft preventDefault und macht
|
|
||||||
den Link damit tot, und ihr pointerdown klappt das Menue zu,
|
|
||||||
das der Hover gerade aufgeklappt hat — zwei Herren ueber
|
|
||||||
dasselbe `open`, das Ergebnis war Flackern statt Navigation.
|
|
||||||
Hier klappt allein der Hover auf, und der Klick folgt dem
|
|
||||||
Link wie bei jedem anderen Menuepunkt.
|
|
||||||
-->
|
|
||||||
<a
|
|
||||||
{...props}
|
|
||||||
href="/dashboard/team"
|
|
||||||
class={className}
|
|
||||||
onmouseenter={enter}
|
|
||||||
onmouseleave={leave}
|
|
||||||
onpointerdown={undefined}
|
|
||||||
onpointerup={undefined}
|
|
||||||
onkeydown={undefined}
|
|
||||||
onclick={() => {
|
|
||||||
menuOpen = false
|
|
||||||
onnavigate?.()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Users class="h-4 w-4" />
|
<Users class="h-4 w-4" />
|
||||||
<span>Team</span>
|
<span>Team</span>
|
||||||
<ChevronDown class="h-3 w-3 opacity-50" />
|
<ChevronDown class="h-3 w-3 opacity-50" />
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DropdownMenu.Trigger>
|
</NavigationMenu.Trigger>
|
||||||
|
|
||||||
<!--
|
<NavigationMenu.Content class="min-w-56">
|
||||||
Aufgeklappt wird beim Ueberfahren, nicht auf Ansage: Das Menue darf
|
|
||||||
deshalb weder den Fokus an sich ziehen noch das Scrollen sperren —
|
|
||||||
beides gehoert zu einem Dialog, nicht zu einem Menuepunkt, ueber den
|
|
||||||
der Zeiger nur streift.
|
|
||||||
|
|
||||||
Auch der Fokus beim Schliessen bleibt aus. bits-ui gibt ihn sonst an
|
|
||||||
den Trigger zurueck, und weil hier der Zeiger schliesst und nicht die
|
|
||||||
Tastatur, stuende danach ein Fokusring auf dem Menuepunkt — fuer
|
|
||||||
jemanden, der nur darueber gefahren ist.
|
|
||||||
-->
|
|
||||||
<DropdownMenu.Content
|
|
||||||
align="start"
|
|
||||||
class="min-w-56"
|
|
||||||
trapFocus={false}
|
|
||||||
preventScroll={false}
|
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
onmouseenter={enter}
|
|
||||||
onmouseleave={leave}
|
|
||||||
>
|
|
||||||
{#each teams.records as t (t.id)}
|
{#each teams.records as t (t.id)}
|
||||||
<DropdownMenu.Item
|
<button
|
||||||
|
type="button"
|
||||||
onclick={() => switchTo(t.id)}
|
onclick={() => switchTo(t.id)}
|
||||||
class={t.id === teams.activeId ? 'bg-accent' : ''}
|
class="{itemClass} {t.id === teams.activeId ? 'bg-accent' : ''}"
|
||||||
>
|
>
|
||||||
<span class="flex-1">{t.name}</span>
|
<span class="flex-1 truncate">{t.name}</span>
|
||||||
{#if teams.isOwner(t)}
|
{#if teams.isOwner(t)}
|
||||||
<span class="text-xs text-muted-foreground">Owner</span>
|
<span class="text-xs text-muted-foreground">Owner</span>
|
||||||
{:else if teams.isAdmin(t)}
|
{:else if teams.isAdmin(t)}
|
||||||
<span class="text-xs text-muted-foreground">Admin</span>
|
<span class="text-xs text-muted-foreground">Admin</span>
|
||||||
{/if}
|
{/if}
|
||||||
</DropdownMenu.Item>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<DropdownMenu.Item disabled>Noch in keinem Team</DropdownMenu.Item>
|
<p class="px-2 py-1.5 text-sm text-muted-foreground">Noch in keinem Team</p>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<DropdownMenu.Separator />
|
<div class="my-1 h-px bg-border"></div>
|
||||||
<DropdownMenu.Item onclick={() => (createDialog = true)}>
|
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
|
||||||
Team erstellen
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
|
|
||||||
<!-- Ausserhalb des Menues: mit dem Klick schliesst sich das Dropdown, und ein
|
<button type="button" onclick={() => (createDialog = true)} class={itemClass}>
|
||||||
darin gerendeter Dialog verschwaende mit ihm. -->
|
<Plus class="h-4 w-4" />
|
||||||
|
Team erstellen
|
||||||
|
</button>
|
||||||
|
</NavigationMenu.Content>
|
||||||
|
</NavigationMenu.Item>
|
||||||
|
|
||||||
|
<!-- Ausserhalb des Menues: mit dem Klick klappt es zu, und ein darin
|
||||||
|
gerendeter Dialog verschwaende mit ihm. -->
|
||||||
<CreateTeamDialog bind:open={createDialog} />
|
<CreateTeamDialog bind:open={createDialog} />
|
||||||
|
|
|
||||||
28
frontend/src/lib/components/ui/navigation-menu/index.ts
Normal file
28
frontend/src/lib/components/ui/navigation-menu/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import Content from "./navigation-menu-content.svelte";
|
||||||
|
import Indicator from "./navigation-menu-indicator.svelte";
|
||||||
|
import Item from "./navigation-menu-item.svelte";
|
||||||
|
import Link from "./navigation-menu-link.svelte";
|
||||||
|
import List from "./navigation-menu-list.svelte";
|
||||||
|
import Trigger from "./navigation-menu-trigger.svelte";
|
||||||
|
import Viewport from "./navigation-menu-viewport.svelte";
|
||||||
|
import Root from "./navigation-menu.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Indicator,
|
||||||
|
Item,
|
||||||
|
Link,
|
||||||
|
List,
|
||||||
|
Trigger,
|
||||||
|
Viewport,
|
||||||
|
//
|
||||||
|
Root as NavigationMenuRoot,
|
||||||
|
Content as NavigationMenuContent,
|
||||||
|
Indicator as NavigationMenuIndicator,
|
||||||
|
Item as NavigationMenuItem,
|
||||||
|
Link as NavigationMenuLink,
|
||||||
|
List as NavigationMenuList,
|
||||||
|
Trigger as NavigationMenuTrigger,
|
||||||
|
Viewport as NavigationMenuViewport,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.ContentProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
class={cn(
|
||||||
|
"p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95 top-0 left-0 w-full group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.IndicatorProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
class={cn(
|
||||||
|
"data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<div class="rounded-tl-sm bg-border shadow-md relative top-[60%] h-2 w-2 rotate-45"></div>
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.ItemProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
class={cn("cn-navigation-menu-item relative", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.LinkProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
class={cn("flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.ListProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
class={cn(
|
||||||
|
"gap-0 group flex flex-1 list-none items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts" module>
|
||||||
|
import { tv } from "tailwind-variants";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
export const navigationMenuTriggerStyle = tv({
|
||||||
|
base: "rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center outline-none disabled:pointer-events-none",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
class={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<ChevronDownIcon class="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.ViewportProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("absolute start-0 top-full isolate z-50 flex justify-center")}>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
class={cn(
|
||||||
|
"rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:zoom-in-90 data-closed:animate-out data-closed:zoom-out-90 origin-top-center relative mt-1.5 h-[calc(var(--bits-navigation-menu-viewport-height)+1rem)] w-full overflow-hidden md:w-[calc(var(--bits-navigation-menu-viewport-width)+1rem)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import NavigationMenuViewport from "./navigation-menu-viewport.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
viewport = true,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: NavigationMenuPrimitive.RootProps & {
|
||||||
|
viewport?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
class={cn(
|
||||||
|
"max-w-max group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if viewport}
|
||||||
|
<NavigationMenuViewport />
|
||||||
|
{/if}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
|
@ -20,9 +20,11 @@
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import Logo from '$lib/components/Logo.svelte'
|
import Logo from '$lib/components/Logo.svelte'
|
||||||
import TeamMenu from '$lib/components/TeamMenu.svelte'
|
import TeamMenu from '$lib/components/TeamMenu.svelte'
|
||||||
|
import CreateTeamDialog from '$lib/components/CreateTeamDialog.svelte'
|
||||||
|
import * as NavigationMenu from '@/components/ui/navigation-menu'
|
||||||
import Avatar from '$lib/components/Avatar.svelte'
|
import Avatar from '$lib/components/Avatar.svelte'
|
||||||
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
|
import RunningTimesToast from '$lib/components/RunningTimesToast.svelte'
|
||||||
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Route, Settings, Users } from 'lucide-svelte'
|
import { Home, Calendar, LogOut, Menu, X, Moon, Sun, Plus, Route, Settings, Users } from 'lucide-svelte'
|
||||||
import { toggleMode, mode } from 'mode-watcher'
|
import { toggleMode, mode } from 'mode-watcher'
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
|
|
||||||
|
|
@ -47,6 +49,10 @@
|
||||||
|
|
||||||
let isMobileMenuOpen = $state(false)
|
let isMobileMenuOpen = $state(false)
|
||||||
|
|
||||||
|
// Der Dialog gehoert nicht ins mobile Menue: Das schliesst sich beim
|
||||||
|
// Klick, und der Dialog verschwaende mit ihm.
|
||||||
|
let createTeamDialog = $state(false)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
goto('/login', { replaceState: true })
|
goto('/login', { replaceState: true })
|
||||||
|
|
@ -180,13 +186,21 @@
|
||||||
<Logo team={teams.active} class="size-[4.5rem] -my-3" />
|
<Logo team={teams.active} class="size-[4.5rem] -my-3" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="hidden md:flex items-center gap-2">
|
<!--
|
||||||
|
viewport={false}: Das Panel haengt unter seinem
|
||||||
|
Menuepunkt, statt als animierte Flaeche ueber die
|
||||||
|
ganze Leiste zu fahren. Bei einem einzigen
|
||||||
|
aufklappbaren Punkt waere das grosse Kino ohne
|
||||||
|
Stueck. -->
|
||||||
|
<NavigationMenu.Root viewport={false} class="hidden md:flex">
|
||||||
|
<NavigationMenu.List class="items-center gap-2">
|
||||||
{#each nav as item}
|
{#each nav as item}
|
||||||
{@const Icon = item.icon}
|
{@const Icon = item.icon}
|
||||||
{#if item.submenu}
|
{#if item.submenu}
|
||||||
<TeamMenu class={navClass(item.href)} />
|
<TeamMenu class={navClass(item.href)} />
|
||||||
{:else}
|
{:else}
|
||||||
<a
|
<NavigationMenu.Item>
|
||||||
|
<NavigationMenu.Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={navClass(item.href, dashHover && item.href === '/dashboard')}
|
class={navClass(item.href, dashHover && item.href === '/dashboard')}
|
||||||
onmouseenter={() => item.href === '/dashboard' && (dashHover = true)}
|
onmouseenter={() => item.href === '/dashboard' && (dashHover = true)}
|
||||||
|
|
@ -194,10 +208,12 @@
|
||||||
>
|
>
|
||||||
<Icon class="h-4 w-4" />
|
<Icon class="h-4 w-4" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</a>
|
</NavigationMenu.Link>
|
||||||
|
</NavigationMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</NavigationMenu.List>
|
||||||
|
</NavigationMenu.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rechts: wer man ist, und was man mit sich tun kann.
|
<!-- Rechts: wer man ist, und was man mit sich tun kann.
|
||||||
|
|
@ -263,12 +279,6 @@
|
||||||
<nav class="px-2 py-2 space-y-1">
|
<nav class="px-2 py-2 space-y-1">
|
||||||
{#each nav as item}
|
{#each nav as item}
|
||||||
{@const Icon = item.icon}
|
{@const Icon = item.icon}
|
||||||
{#if item.submenu}
|
|
||||||
<TeamMenu
|
|
||||||
class="w-full {navClass(item.href)}"
|
|
||||||
onnavigate={() => (isMobileMenuOpen = false)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onclick={() => (isMobileMenuOpen = false)}
|
onclick={() => (isMobileMenuOpen = false)}
|
||||||
|
|
@ -277,6 +287,46 @@
|
||||||
<Icon class="h-4 w-4" />
|
<Icon class="h-4 w-4" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Auf dem Touchscreen gibt es kein Ueberfahren,
|
||||||
|
also auch kein Aufklappen. Die Teams stehen
|
||||||
|
hier eingerueckt unter ihrem Menuepunkt —
|
||||||
|
sichtbar statt versteckt, und der einzige Weg,
|
||||||
|
auf dem sich ohne Zeiger wechseln laesst.
|
||||||
|
-->
|
||||||
|
{#if item.submenu}
|
||||||
|
<div class="ml-6 space-y-1 border-l pl-3">
|
||||||
|
{#each teams.records as t (t.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
teams.setActive(t.id)
|
||||||
|
isMobileMenuOpen = false
|
||||||
|
}}
|
||||||
|
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent {t.id ===
|
||||||
|
teams.activeId
|
||||||
|
? 'font-medium text-foreground'
|
||||||
|
: 'text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
<span class="flex-1 truncate">{t.name}</span>
|
||||||
|
{#if t.id === teams.activeId}
|
||||||
|
<span class="text-xs text-muted-foreground">aktiv</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
isMobileMenuOpen = false
|
||||||
|
createTeamDialog = true
|
||||||
|
}}
|
||||||
|
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
Team erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<Separator class="my-2" />
|
<Separator class="my-2" />
|
||||||
|
|
@ -313,5 +363,7 @@
|
||||||
|
|
||||||
<!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. -->
|
<!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. -->
|
||||||
<RunningTimesToast />
|
<RunningTimesToast />
|
||||||
|
|
||||||
|
<CreateTeamDialog bind:open={createTeamDialog} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue