the hive dashboard uses the swarm UI's links menu, beside the profile avatar
The links menu moves into @hive/shared and takes its links as a prop. The swarm UI feeds it /api/links as before; the dashboard feeds it stats, API docs and the swarm, and mounts it next to the profile menu in place of its own hamburger on the left. A link opens in a new tab unless its entry says it is a page of the same site.
This commit is contained in:
parent
b2e3b05198
commit
464b52004b
5 changed files with 192 additions and 239 deletions
|
|
@ -56,7 +56,10 @@
|
|||
"./expand-details-setting.js": "./src/settings/ExpandDetailsSetting.tsx",
|
||||
"./settings-menu.css": "./src/settings/SettingsMenu.css",
|
||||
"./user-menu.js": "./src/user-menu/UserMenu.tsx",
|
||||
"./user-menu.css": "./src/user-menu/UserMenu.css"
|
||||
"./user-menu.css": "./src/user-menu/UserMenu.css",
|
||||
"./links-menu.js": "./src/links-menu/LinksMenu.tsx",
|
||||
"./links-menu.css": "./src/links-menu/LinksMenu.css",
|
||||
"./links.js": "./src/links-menu/links.ts"
|
||||
},
|
||||
"files": [
|
||||
"src/"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
creates a new `.jg-node` DOM node when its `key` (the node id) is
|
||||
genuinely new, so this only plays once per node, the first time it
|
||||
appears in a fetched snapshot. Same shape as Shell.css's
|
||||
`shell-page-enter` / LinksMenu.css's `links-menu-popover-enter` —
|
||||
`shell-page-enter` / the links menu's `links-menu-popover-enter` —
|
||||
see either for the three-rule motion-guard this mirrors. */
|
||||
.jg-node {
|
||||
margin: 0;
|
||||
|
|
|
|||
87
frontend/packages/shared/src/links-menu/LinksMenu.css
Normal file
87
frontend/packages/shared/src/links-menu/LinksMenu.css
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/* <LinksMenu> (@hive/shared) — a single header icon-button + popover. Kept visually
|
||||
quiet (no border/fill until interacted with) so it reads as chrome,
|
||||
not another nav item. Button is sized to the same touch-target floor
|
||||
as the shared `ui/` kit (2.75em ≈ 44px, WCAG 2.5.5) even though it
|
||||
isn't built from that kit — a bespoke icon trigger, not a form
|
||||
control, but the floor applies regardless of which component drew
|
||||
it. No `margin-left: auto` here: the mounting page's header owns pushing the
|
||||
action group to the right edge (the swarm UI's `.shell-header-actions`,
|
||||
the dashboard's `.dashboard-chrome-actions`). */
|
||||
.links-menu {
|
||||
position: relative;
|
||||
}
|
||||
.links-menu-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75em;
|
||||
height: 2.75em;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.4em;
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.links-menu-button:hover,
|
||||
.links-menu-button[aria-expanded="true"] {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.links-menu-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4em);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 10em;
|
||||
padding: 0.4em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5em;
|
||||
background: var(--bg-elev);
|
||||
box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3);
|
||||
animation: links-menu-popover-enter 140ms ease;
|
||||
}
|
||||
/* Mount-triggered, not a state transition — the popover only exists in
|
||||
the DOM while open (`{open ? <div> : null}`), so a keyframe animation
|
||||
on entry is the right tool, same shape as `.shell-body`'s own
|
||||
`shell-page-enter` (Shell.css) — that's the precedent every popover
|
||||
in this issue's "menus should animate" ask follows. */
|
||||
@keyframes links-menu-popover-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.25em) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.links-menu-popover {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion="reduce"] .links-menu-popover {
|
||||
animation: none;
|
||||
}
|
||||
:root[data-motion="allow"] .links-menu-popover {
|
||||
animation: links-menu-popover-enter 140ms ease;
|
||||
}
|
||||
.links-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
min-height: 2.75em;
|
||||
padding: 0.4em 0.6em;
|
||||
border-radius: 0.35em;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.links-menu-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
81
frontend/packages/shared/src/links-menu/LinksMenu.tsx
Normal file
81
frontend/packages/shared/src/links-menu/LinksMenu.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// <LinksMenu> — a single header icon-button opening a popover of links, next
|
||||
// to the profile avatar. The swarm UI hands it the swarm's service list and
|
||||
// the hive dashboard its own pages; both look and behave the same, which is
|
||||
// why it lives here (it was the swarm UI's, and the dashboard had its own
|
||||
// copy with a hamburger on the left).
|
||||
//
|
||||
// A menu with no links renders nothing: no button beats a dead one.
|
||||
//
|
||||
// Styles live in `@hive/shared/links-menu.css`, which the mounting page
|
||||
// imports or `@import`s itself: the dashboard's JS bundle loads `.css`
|
||||
// imports as text, so a component that imported it would not style there
|
||||
// (the same split as `UserMenu`).
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { LinkIcon } from "../icons.js";
|
||||
import { linkAttrs, type MenuLink } from "./links.js";
|
||||
|
||||
export type { MenuLink };
|
||||
|
||||
export function LinksMenu({
|
||||
links,
|
||||
label = "links",
|
||||
}: {
|
||||
links: MenuLink[];
|
||||
/** The button's accessible name. */
|
||||
label?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(e: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node))
|
||||
setOpen(false);
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (links.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div class="links-menu" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
class="links-menu-button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<LinkIcon />
|
||||
</button>
|
||||
{open ? (
|
||||
<div class="links-menu-popover" role="menu">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.url}
|
||||
class="links-menu-item"
|
||||
href={link.url}
|
||||
{...linkAttrs(link)}
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{link.icon ? <span aria-hidden="true">{link.icon}</span> : null}
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
frontend/packages/shared/src/links-menu/links.ts
Normal file
25
frontend/packages/shared/src/links-menu/links.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// What the links menu shows for one link, without a DOM: where it opens and
|
||||
// what it is called. Shared by the swarm UI (its `/api/links`) and the hive
|
||||
// dashboard (its stats and API docs pages, and the swarm UI).
|
||||
|
||||
/** One entry of the links menu. */
|
||||
export interface MenuLink {
|
||||
label: string;
|
||||
/** A glyph before the label; none leaves the label alone. */
|
||||
icon?: string;
|
||||
url: string;
|
||||
/**
|
||||
* Opens in a new tab. Defaults to true: a link the menu was handed from
|
||||
* elsewhere (the swarm's service list) leaves the page. A page of the
|
||||
* same site says `false` and opens in place.
|
||||
*/
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
/** The anchor attributes that make a link open where its entry says. */
|
||||
export function linkAttrs(link: MenuLink): {
|
||||
target?: string;
|
||||
rel?: string;
|
||||
} {
|
||||
return link.external === false ? {} : { target: "_blank", rel: "noreferrer" };
|
||||
}
|
||||
Loading…
Reference in a new issue