Files
MujicaUI/navigation_menu.go
T
ZacharyZhang-NY c22a09d327 Refactor: popups share overlayStyle and overlayEnter
internal.PopIn is gone. Breadcrumb, NavigationMenu, Pagination, Menubar
panels, CommandPalette, Select (and the other selectPanel users),
datetime popups, ColorPicker and the StatusBar overflow now style their
panel with overlayStyle(c, panel, overlayEnter(c, panel)): one shadow
(0 6 18, 16% black, scaled by entry) and one 160 ms entry fade.

Snapshots: color_picker and command_palette (light and dark) change only
in the panel shadow. ColorPicker loses its 0 4 12 18% shadow for the
softer shared one; CommandPalette loses its heavy 0 12 32 30% shadow,
and its backdrop now fades by alpha with the panel instead of through
the backdrop's opacity. Fade and reduced-motion tests pass unchanged.
2026-10-05 13:41:29 -04:00

223 lines
6.4 KiB
Go

package mujica
import (
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// NavigationLink is a page of a NavigationMenu, or with Items a group of pages.
type NavigationLink struct {
Label string
// Path is pushed on the router; groups need none.
Path string
Icon *ui.SVG
Description string
Items []NavigationLink
}
// NavigationMenuOptions configure a NavigationMenu.
type NavigationMenuOptions struct {
// History shows back and forward buttons for the router.
History bool
// Label names the menu for assistive technology.
Label string
}
// NavigationMenu shows links pushing pages on router; the router's path marks the current one.
func NavigationMenu(c *ui.Context, router *ui.Router, links []NavigationLink, opts NavigationMenuOptions) *ui.Element {
if router == nil {
panic("mujica: NavigationMenu needs a router")
}
if len(links) == 0 {
panic("mujica: NavigationMenu needs links")
}
k := Tokens(c)
u := Density(c).Unit()
path := router.Path()
label := opts.Label
if label == "" {
label = msg(c, "navigation_menu.label")
}
push := func(p string) {
if p != path {
slog.Debug("mujica: navigation menu push", "from", path, "to", p)
router.Push(p)
}
}
row := ui.Row(c).AlignItems(ui.Center).Gap(u * 2)
row.Children(func() {
if opts.History {
ui.Row(c).Gap(u).Children(func() {
navigationMenuHistory(c, ui.BackButton(c, router), msg(c, "navigation_menu.back"))
navigationMenuHistory(c, ui.ForwardButton(c, router), msg(c, "navigation_menu.forward"))
})
ui.Box(c).Size(1, 20).Background(k.Ornament).Role(ui.RoleNone)
}
ui.Row(c).AlignItems(ui.Center).Gap(u).FocusGroup(ui.Horizontal).Role(ui.RoleToolbar).Label(label).Children(func() {
for _, l := range links {
navigationMenuTop(c, l, path, push)
}
})
})
return row
}
func navigationMenuHistory(c *ui.Context, b *ui.Element, name string) {
k := Tokens(c)
d := Density(c)
b.Label(name).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()).Radius(theme.ControlRadius).Background(k.Surface)
internal.Ring(b, k.Focus, theme.ControlRadius)
}
// navigationMenuActive reports whether l or a link inside it is the page at path.
func navigationMenuActive(l NavigationLink, path string) bool {
// The root page is current only on itself, not under every path.
if l.Path != "" && (path == l.Path || l.Path != "/" && strings.HasPrefix(path, strings.TrimSuffix(l.Path, "/")+"/")) {
return true
}
for _, it := range l.Items {
if navigationMenuActive(it, path) {
return true
}
}
return false
}
func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(string)) {
if l.Label == "" {
panic("mujica: NavigationLink needs a Label")
}
if l.Items == nil && l.Path == "" {
panic("mujica: NavigationLink needs a Path or Items")
}
k := Tokens(c)
d := Density(c)
u := d.Unit()
on := navigationMenuActive(l, path)
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, u*3).Gap(u * 1.5).Radius(theme.ControlRadius).
Label(l.Label).Cursor(ui.CursorPointer)
open := ui.Local(b, "open", func() bool { return false })
if l.Items == nil {
b.Role(ui.RoleLink)
} else {
b.Role(ui.RoleMenuButton)
}
fg := k.TextMuted
if on {
b.FontWeight(600)
fg = k.Text
}
if b.Hovered() || *open {
b.Background(k.SurfaceHover)
fg = k.Text
}
internal.Ease(b, Motion(c, theme.StateDuration), fg)
b.FocusRing(false).DrawOver(func(p *ui.Painter, r ui.Rect) {
if on {
p.Fill(ui.Rect{X: r.X + u*3, Y: r.Y + r.H - 2, W: r.W - u*6, H: 2}, k.AccentText, 1)
}
if b.FocusVisible() {
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
}
})
b.Children(func() {
if l.Icon != nil {
ui.Icon(c, l.Icon).Size(16, 16)
}
ui.Text(c, l.Label).SingleLine()
if l.Items != nil {
ui.Icon(c, icons.Must("chevron-down")).Size(12, 12).TextColor(k.TextMuted)
}
})
if l.Items == nil {
if b.Clicked() {
push(l.Path)
}
return
}
if b.Clicked() {
*open = !*open
} else if b.Shortcut(0, ui.KeyDown) {
*open = true
}
ui.PopoverBase(c, b, open, func(panel *ui.Element) {
overlayStyle(c, panel, overlayEnter(c, panel))
// Capped to the room beside the anchor; the links scroll inside.
_, wh := c.Size()
r := b.Bounds()
panel.Margin(u, 0, 0, 0).Padding(u * 2).MaxHeight(max(r.Y, wh-r.Y-r.H) - 16).MinWidth(240).MaxWidth(360)
ui.Scroll(c).Shrink(1).Gap(u).FocusGroup(ui.Vertical).Children(func() {
navigationMenuPanel(c, l.Items, path, open, push)
})
})
}
// navigationMenuPanel lists a group's links, its subgroups under serif headings.
func navigationMenuPanel(c *ui.Context, items []NavigationLink, path string, open *bool, push func(string)) {
k := Tokens(c)
u := Density(c).Unit()
first := true
var list func(items []NavigationLink)
list = func(items []NavigationLink) {
for _, it := range items {
if it.Label == "" {
panic("mujica: NavigationLink needs a Label")
}
if it.Items != nil {
ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).
Padding(u*2, u*2, u, u*2).SingleLine()
list(it.Items)
continue
}
if it.Path == "" {
panic("mujica: NavigationLink needs a Path or Items")
}
if navigationMenuItem(c, it, it.Path == path, first).Clicked() {
*open = false
push(it.Path)
}
first = false
}
}
list(items)
}
func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
e := ui.ButtonBase(c).Role(ui.RoleLink).Justify(ui.Start).AlignItems(ui.Start).Gap(u*3).
Padding(u*2, u*3).Radius(theme.ControlRadius).Label(l.Label).TextColor(k.Text).Cursor(ui.CursorPointer)
if first {
e.AutoFocus()
}
internal.Ring(e, k.Focus, theme.ControlRadius)
switch {
case on:
e.Background(k.Selection).FontWeight(600)
e.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 3, H: r.H - 8}, k.AccentText, 1.5)
})
case e.Hovered():
e.Background(k.SurfaceHover)
}
internal.Ease(e, Motion(c, theme.StateDuration), k.Text)
e.Children(func() {
if l.Icon != nil {
ui.Icon(c, l.Icon).Size(16, 16).Margin(2, 0, 0, 0)
}
ui.Column(c).Gap(2).MinWidth(0).Children(func() {
ui.Text(c, l.Label).SingleLine()
if l.Description != "" {
ui.Text(c, l.Description).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).FontWeight(400)
}
})
})
return e
}