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": {
|
||||
"@internationalized/date": "^3.12.1",
|
||||
"@lucide/svelte": "^1.16.0",
|
||||
"@lucide/svelte": "^1.42.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwind-variants": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.2",
|
||||
|
|
@ -812,9 +812,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@lucide/svelte": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.16.0.tgz",
|
||||
"integrity": "sha512-AvvPJnaWxeiNkAljI5MsSEc84yHPLMaWQIAJOcbX7k9au/f9ITS7cxTTQiautDiOFKVOXiYdZ+d6mtl88J+Kbg==",
|
||||
"version": "1.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.42.0.tgz",
|
||||
"integrity": "sha512-9vOi3EdzJht5aD7F5OraoxrK2GyyL1Y+kVXEtIg4+IAdKmDBrwYK2d/x7akC89uA+cvkfjcw+IuzH0vfd/TuzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
|
|
@ -5737,13 +5737,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tailwind-variants": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
|
||||
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz",
|
||||
"integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.x",
|
||||
"node": ">=16.9.x",
|
||||
"pnpm": ">=7.x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
@ -5753,6 +5753,9 @@
|
|||
"peerDependenciesMeta": {
|
||||
"tailwind-merge": {
|
||||
"optional": true
|
||||
},
|
||||
"tailwindcss": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.12.1",
|
||||
"@lucide/svelte": "^1.16.0",
|
||||
"@lucide/svelte": "^1.42.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwind-variants": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.2",
|
||||
|
|
|
|||
|
|
@ -4,18 +4,28 @@
|
|||
* Ueberfahren aufklappt: darunter der Wechsel zwischen den eigenen Teams
|
||||
* und der Weg zu einem neuen.
|
||||
*
|
||||
* Der Punkt selbst ist ein Link auf die Teamseite. Ein eigener Eintrag
|
||||
* „Uebersicht" waere die zweite Tuer zu derselben Seite, und eine
|
||||
* Ueberschrift ueber drei Teamnamen sagt nichts, was die Namen nicht
|
||||
* schon sagen.
|
||||
* Traegt bits-uis NavigationMenu und nicht das DropdownMenu. Ein Dropdown
|
||||
* ist ein Menue, das auf Ansage aufgeht; eine Navigationsleiste ist etwas
|
||||
* anderes, und die Unterschiede sind genau die, die hier gebraucht werden:
|
||||
* 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
|
||||
* die Teamseite, und gewechselt wird in deren Liste „Meine Teams".
|
||||
* die Teamseite, und gewechselt wird in deren Liste.
|
||||
*/
|
||||
import { goto } from '$app/navigation'
|
||||
import { getTeamContext } from '$lib/stores/teams.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'
|
||||
|
||||
let {
|
||||
|
|
@ -26,21 +36,6 @@
|
|||
const teams = getTeamContext()
|
||||
|
||||
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
|
||||
|
|
@ -52,86 +47,49 @@
|
|||
goto('/dashboard/team')
|
||||
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>
|
||||
|
||||
<DropdownMenu.Root bind:open={menuOpen}>
|
||||
<DropdownMenu.Trigger>
|
||||
<NavigationMenu.Item value="team">
|
||||
<NavigationMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<!--
|
||||
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?.()
|
||||
}}
|
||||
>
|
||||
<a {...props} href="/dashboard/team" class={className} onclick={() => onnavigate?.()}>
|
||||
<Users class="h-4 w-4" />
|
||||
<span>Team</span>
|
||||
<ChevronDown class="h-3 w-3 opacity-50" />
|
||||
</a>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
</NavigationMenu.Trigger>
|
||||
|
||||
<!--
|
||||
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}
|
||||
>
|
||||
<NavigationMenu.Content class="min-w-56">
|
||||
{#each teams.records as t (t.id)}
|
||||
<DropdownMenu.Item
|
||||
<button
|
||||
type="button"
|
||||
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)}
|
||||
<span class="text-xs text-muted-foreground">Owner</span>
|
||||
{:else if teams.isAdmin(t)}
|
||||
<span class="text-xs text-muted-foreground">Admin</span>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
</button>
|
||||
{: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}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={() => (createDialog = true)}>
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
Team erstellen
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
|
||||
<!-- Ausserhalb des Menues: mit dem Klick schliesst sich das Dropdown, und ein
|
||||
darin gerendeter Dialog verschwaende mit ihm. -->
|
||||
<button type="button" onclick={() => (createDialog = true)} class={itemClass}>
|
||||
<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} />
|
||||
|
|
|
|||
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 Logo from '$lib/components/Logo.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 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 { onDestroy, onMount } from 'svelte'
|
||||
|
||||
|
|
@ -47,6 +49,10 @@
|
|||
|
||||
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 () => {
|
||||
if (!auth.user) {
|
||||
goto('/login', { replaceState: true })
|
||||
|
|
@ -180,13 +186,21 @@
|
|||
<Logo team={teams.active} class="size-[4.5rem] -my-3" />
|
||||
</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}
|
||||
{@const Icon = item.icon}
|
||||
{#if item.submenu}
|
||||
<TeamMenu class={navClass(item.href)} />
|
||||
{:else}
|
||||
<a
|
||||
<NavigationMenu.Item>
|
||||
<NavigationMenu.Link
|
||||
href={item.href}
|
||||
class={navClass(item.href, dashHover && item.href === '/dashboard')}
|
||||
onmouseenter={() => item.href === '/dashboard' && (dashHover = true)}
|
||||
|
|
@ -194,10 +208,12 @@
|
|||
>
|
||||
<Icon class="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</NavigationMenu.Link>
|
||||
</NavigationMenu.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu.Root>
|
||||
</div>
|
||||
|
||||
<!-- Rechts: wer man ist, und was man mit sich tun kann.
|
||||
|
|
@ -263,12 +279,6 @@
|
|||
<nav class="px-2 py-2 space-y-1">
|
||||
{#each nav as item}
|
||||
{@const Icon = item.icon}
|
||||
{#if item.submenu}
|
||||
<TeamMenu
|
||||
class="w-full {navClass(item.href)}"
|
||||
onnavigate={() => (isMobileMenuOpen = false)}
|
||||
/>
|
||||
{:else}
|
||||
<a
|
||||
href={item.href}
|
||||
onclick={() => (isMobileMenuOpen = false)}
|
||||
|
|
@ -277,6 +287,46 @@
|
|||
<Icon class="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</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}
|
||||
{/each}
|
||||
<Separator class="my-2" />
|
||||
|
|
@ -313,5 +363,7 @@
|
|||
|
||||
<!-- Laufende Zeiten bleiben auf jeder Dashboard-Seite im Blick. -->
|
||||
<RunningTimesToast />
|
||||
|
||||
<CreateTeamDialog bind:open={createTeamDialog} />
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Reference in a new issue