Compare commits
2 Commits
fd67af7101
...
fd429bdcdd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd429bdcdd | ||
|
|
bbebccfcf3 |
165
Frontend/src/components/shell/TopBar/ProfileMenu.module.scss
Normal file
165
Frontend/src/components/shell/TopBar/ProfileMenu.module.scss
Normal file
@@ -0,0 +1,165 @@
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-2));
|
||||
right: 0;
|
||||
width: min(21rem, calc(100vw - (var(--space-4) * 2)));
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: calc(var(--radius-lg) + 0.1rem);
|
||||
background: color-mix(in srgb, var(--color-surface-muted) 96%, transparent);
|
||||
box-shadow: 0 18px 36px color-mix(in srgb, black 16%, transparent);
|
||||
backdrop-filter: blur(18px);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
align-self: center;
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.avatarRing {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
conic-gradient(
|
||||
from 0deg,
|
||||
transparent 0deg 24deg,
|
||||
var(--color-primary-1) 24deg 118deg,
|
||||
transparent 118deg 144deg,
|
||||
var(--color-primary-2) 144deg 238deg,
|
||||
transparent 238deg 264deg,
|
||||
var(--color-primary-3) 264deg 356deg,
|
||||
transparent 356deg 360deg
|
||||
);
|
||||
mask: radial-gradient(circle, transparent 64%, black 67%);
|
||||
-webkit-mask: radial-gradient(circle, transparent 64%, black 67%);
|
||||
}
|
||||
|
||||
.avatarCore {
|
||||
@include text-label;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 78%;
|
||||
height: 78%;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.summaryCopy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
.name,
|
||||
.itemLabel {
|
||||
@include text-label;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.email,
|
||||
.role,
|
||||
.context {
|
||||
@include text-caption;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.context {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 2.65rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 160ms var(--easing-standard),
|
||||
border-color 160ms var(--easing-standard),
|
||||
color 160ms var(--easing-standard);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: color-mix(in srgb, var(--color-surface) 88%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-border) 20%, transparent);
|
||||
}
|
||||
|
||||
.item:focus-visible {
|
||||
outline: none;
|
||||
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-accent-soft) 52%, transparent);
|
||||
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-soft) 14%, transparent);
|
||||
}
|
||||
|
||||
.itemDanger {
|
||||
color: color-mix(in srgb, var(--color-primary-3) 74%, var(--color-text) 26%);
|
||||
}
|
||||
|
||||
.itemDanger:hover,
|
||||
.itemDanger:focus-visible {
|
||||
background: color-mix(in srgb, var(--color-primary-3) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-primary-3) 16%, transparent);
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
@include respond-down(mobile) {
|
||||
.menu {
|
||||
width: min(20rem, calc(100vw - (var(--space-3) * 2)));
|
||||
}
|
||||
}
|
||||
61
Frontend/src/components/shell/TopBar/ProfileMenu.tsx
Normal file
61
Frontend/src/components/shell/TopBar/ProfileMenu.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { For, type JSX } from "solid-js";
|
||||
import { User } from "../../../lib/icons";
|
||||
import { activeUserProfile, profileMenuSections } from "../data/shell.data";
|
||||
import styles from "./ProfileMenu.module.scss";
|
||||
|
||||
type ProfileMenuProps = {
|
||||
id: string;
|
||||
menuRef: (element: HTMLDivElement) => void;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
|
||||
return (
|
||||
<div id={props.id} class={styles.menu} role="menu" aria-label="Profile menu" ref={props.menuRef}>
|
||||
<div class={styles.summary}>
|
||||
<div class={styles.avatar} aria-hidden="true">
|
||||
<span class={styles.avatarRing} />
|
||||
<span class={styles.avatarCore}>
|
||||
<User size={16} strokeWidth={2} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class={styles.summaryCopy}>
|
||||
<strong class={styles.name}>{activeUserProfile.name}</strong>
|
||||
<span class={styles.email}>{activeUserProfile.email}</span>
|
||||
<span class={styles.role}>{activeUserProfile.roleLabel}</span>
|
||||
<span class={styles.context}>{activeUserProfile.contextLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<For each={profileMenuSections}>
|
||||
{(section): JSX.Element => (
|
||||
<div class={styles.section}>
|
||||
<For each={section.items}>
|
||||
{(item): JSX.Element => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
classList={{
|
||||
[styles.item]: true,
|
||||
[styles.itemDanger]: item.tone === "danger",
|
||||
}}
|
||||
onClick={props.onSelect}
|
||||
>
|
||||
<span class={styles.itemIcon} aria-hidden="true">
|
||||
<Icon size={16} strokeWidth={2} />
|
||||
</span>
|
||||
<span class={styles.itemLabel}>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import type { Theme } from "../../../theme/runtime";
|
||||
import { topBarActions } from "../data/shell.data";
|
||||
import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { UserNavButton } from "./UserNavButton";
|
||||
import { UserNav } from "./UserNav";
|
||||
import styles from "./TopBar.module.scss";
|
||||
|
||||
type TopBarProps = {
|
||||
@@ -37,7 +37,7 @@ export const TopBar = (props: TopBarProps): JSX.Element => {
|
||||
</div>
|
||||
|
||||
<ThemeToggle theme={props.theme} onToggle={props.onToggleTheme} />
|
||||
<UserNavButton />
|
||||
<UserNav />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
5
Frontend/src/components/shell/TopBar/UserNav.module.scss
Normal file
5
Frontend/src/components/shell/TopBar/UserNav.module.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
54
Frontend/src/components/shell/TopBar/UserNav.tsx
Normal file
54
Frontend/src/components/shell/TopBar/UserNav.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createEffect, createSignal, createUniqueId, onCleanup, type JSX } from "solid-js";
|
||||
import { ProfileMenu } from "./ProfileMenu";
|
||||
import { UserNavButton } from "./UserNavButton";
|
||||
import styles from "./UserNav.module.scss";
|
||||
|
||||
export const UserNav = (): JSX.Element => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const menuId = createUniqueId();
|
||||
let rootRef: HTMLDivElement | undefined;
|
||||
let menuRef: HTMLDivElement | undefined;
|
||||
|
||||
const closeMenu = (): void => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const toggleMenu = (): void => {
|
||||
setIsOpen((open) => !open);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (!isOpen()) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent): void => {
|
||||
if (!rootRef) return;
|
||||
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !rootRef.contains(target)) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
menuRef?.querySelector<HTMLButtonElement>("[role='menuitem']")?.focus();
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div class={styles.root} ref={rootRef}>
|
||||
<UserNavButton isOpen={isOpen()} menuId={menuId} onToggle={toggleMenu} />
|
||||
{isOpen() ? <ProfileMenu id={menuId} menuRef={(element) => (menuRef = element)} onSelect={closeMenu} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,7 +23,12 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.userButton:hover .spinContainer {
|
||||
.userButtonOpen {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.userButton:hover .spinContainer,
|
||||
.userButtonOpen .spinContainer {
|
||||
animation-play-state: running;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,27 @@ import type { JSX } from "solid-js";
|
||||
import { User } from "../../../lib/icons";
|
||||
import styles from "./UserNavButton.module.scss";
|
||||
|
||||
export const UserNavButton = (): JSX.Element => {
|
||||
type UserNavButtonProps = {
|
||||
isOpen: boolean;
|
||||
menuId: string;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
export const UserNavButton = (props: UserNavButtonProps): JSX.Element => {
|
||||
return (
|
||||
<button class={styles.userButton} type="button" aria-label="Open profile" title="Open profile">
|
||||
<button
|
||||
classList={{
|
||||
[styles.userButton]: true,
|
||||
[styles.userButtonOpen]: props.isOpen,
|
||||
}}
|
||||
type="button"
|
||||
aria-label={props.isOpen ? "Close profile menu" : "Open profile menu"}
|
||||
title={props.isOpen ? "Close profile menu" : "Open profile menu"}
|
||||
aria-haspopup="menu"
|
||||
aria-controls={props.menuId}
|
||||
aria-expanded={props.isOpen}
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
<span class={styles.spinContainer} aria-hidden="true">
|
||||
<span class={styles.spinRing} />
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
// Path: Frontend/src/components/shell/data/shell.data.ts
|
||||
|
||||
import type { Component } from "solid-js";
|
||||
import { Bell, Folder, Home, LayoutGrid, Search, Settings, User } from "../../../lib/icons";
|
||||
import {
|
||||
Bell,
|
||||
CircleHelp,
|
||||
Folder,
|
||||
Home,
|
||||
Keyboard,
|
||||
LayoutGrid,
|
||||
LogOut,
|
||||
Repeat,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
User,
|
||||
} from "../../../lib/icons";
|
||||
|
||||
type ShellIconProps = {
|
||||
class?: string;
|
||||
@@ -74,6 +87,25 @@ export type TopBarAction = {
|
||||
icon: ShellIcon;
|
||||
};
|
||||
|
||||
export type ProfileMenuAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ShellIcon;
|
||||
tone?: "default" | "danger";
|
||||
};
|
||||
|
||||
export type ProfileMenuSection = {
|
||||
id: string;
|
||||
items: readonly ProfileMenuAction[];
|
||||
};
|
||||
|
||||
export type ActiveUserProfile = {
|
||||
name: string;
|
||||
email: string;
|
||||
roleLabel: string;
|
||||
contextLabel: string;
|
||||
};
|
||||
|
||||
const personalDockActions: readonly ServerDockAction[] = [
|
||||
{ id: "account", label: "Account", icon: User },
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
@@ -136,3 +168,37 @@ export const topBarActions: readonly TopBarAction[] = [
|
||||
{ id: "search", label: "Search", icon: Search },
|
||||
{ id: "inbox", label: "Inbox", icon: Bell },
|
||||
] as const;
|
||||
|
||||
export const activeUserProfile: ActiveUserProfile = {
|
||||
name: "Demo Account",
|
||||
email: "demo@moku.work",
|
||||
roleLabel: "Founder · Product",
|
||||
contextLabel: "Organization Name • Design Systems",
|
||||
};
|
||||
|
||||
export const profileMenuSections: readonly ProfileMenuSection[] = [
|
||||
{
|
||||
id: "account",
|
||||
items: [
|
||||
{ id: "profile", label: "Profile", icon: User },
|
||||
{ id: "account-settings", label: "Account Settings", icon: Settings },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
{ id: "security", label: "Security", icon: Shield },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "preferences",
|
||||
items: [
|
||||
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
|
||||
{ id: "theme-preferences", label: "Theme Preferences", icon: Settings },
|
||||
{ id: "help-support", label: "Help & Support", icon: CircleHelp },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "session",
|
||||
items: [
|
||||
{ id: "switch-account", label: "Switch Account", icon: Repeat },
|
||||
{ id: "sign-out", label: "Sign Out", icon: LogOut, tone: "danger" },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
// Path: Frontend/src/lib/icons/index.ts
|
||||
|
||||
export { default as Bell } from "lucide-solid/icons/bell";
|
||||
export { default as CircleHelp } from "lucide-solid/icons/circle-help";
|
||||
export { default as ChevronDown } from "lucide-solid/icons/chevron-down";
|
||||
export { default as Folder } from "lucide-solid/icons/folder";
|
||||
export { default as Home } from "lucide-solid/icons/house";
|
||||
export { default as Keyboard } from "lucide-solid/icons/keyboard";
|
||||
export { default as LayoutGrid } from "lucide-solid/icons/layout-grid";
|
||||
export { default as LogOut } from "lucide-solid/icons/log-out";
|
||||
export { default as Moon } from "lucide-solid/icons/moon";
|
||||
export { default as Plus } from "lucide-solid/icons/plus";
|
||||
export { default as Repeat } from "lucide-solid/icons/repeat";
|
||||
export { default as Search } from "lucide-solid/icons/search";
|
||||
export { default as Server } from "lucide-solid/icons/server";
|
||||
export { default as Settings } from "lucide-solid/icons/settings";
|
||||
export { default as Shield } from "lucide-solid/icons/shield";
|
||||
export { default as Sun } from "lucide-solid/icons/sun";
|
||||
export { default as User } from "lucide-solid/icons/user";
|
||||
|
||||
Reference in New Issue
Block a user