diff --git a/Frontend/src/components/shell/TopBar/NotificationsButton.module.scss b/Frontend/src/components/shell/TopBar/NotificationsButton.module.scss new file mode 100644 index 0000000..562efc4 --- /dev/null +++ b/Frontend/src/components/shell/TopBar/NotificationsButton.module.scss @@ -0,0 +1,66 @@ +.button { + display: inline-flex; + justify-content: center; + align-items: center; + width: 2.75rem; + height: 2.75rem; + aspect-ratio: 1; + padding: 0; + border: 0; + border-radius: 999px; + flex-shrink: 0; + cursor: pointer; + background: transparent; + color: var(--color-text-muted); + transition: + background-color 220ms var(--easing-standard), + color 220ms var(--easing-standard), + transform 180ms var(--easing-standard); +} + +.button:hover { + background: color-mix(in srgb, var(--color-text) 8%, transparent); + color: var(--color-text); +} + +.buttonOpen { + background: color-mix(in srgb, var(--color-surface) 92%, transparent); + color: var(--color-text); +} + +.button:focus-visible { + outline: none; + background: color-mix(in srgb, var(--color-accent-strong) 12%, transparent); + box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-strong) 28%, transparent); + color: var(--color-text); +} + +.iconWrap { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; +} + +.badge { + @include text-caption; + position: absolute; + top: -0.45rem; + right: -0.7rem; + min-width: 1rem; + height: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 0.24rem; + border: 1px solid color-mix(in srgb, var(--color-surface) 68%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--color-primary-3) 84%, black 16%); + color: white; + font-weight: var(--font-weight-semibold); + line-height: 1; + box-shadow: 0 6px 14px color-mix(in srgb, black 18%, transparent); + pointer-events: none; +} diff --git a/Frontend/src/components/shell/TopBar/NotificationsButton.tsx b/Frontend/src/components/shell/TopBar/NotificationsButton.tsx new file mode 100644 index 0000000..00c8b41 --- /dev/null +++ b/Frontend/src/components/shell/TopBar/NotificationsButton.tsx @@ -0,0 +1,36 @@ +import type { JSX } from "solid-js"; +import { Bell } from "../../../lib/icons"; +import { unreadNotificationCount } from "../data/shell.data"; +import styles from "./NotificationsButton.module.scss"; + +type NotificationsButtonProps = { + isOpen: boolean; + menuId: string; + onToggle: () => void; +}; + +export const NotificationsButton = (props: NotificationsButtonProps): JSX.Element => { + const hasUnread = unreadNotificationCount > 0; + const unreadLabel = hasUnread ? `, ${unreadNotificationCount} unread` : ""; + + return ( + + ); +}; diff --git a/Frontend/src/components/shell/TopBar/NotificationsMenu.module.scss b/Frontend/src/components/shell/TopBar/NotificationsMenu.module.scss new file mode 100644 index 0000000..c2483d9 --- /dev/null +++ b/Frontend/src/components/shell/TopBar/NotificationsMenu.module.scss @@ -0,0 +1,227 @@ +.menu { + position: absolute; + top: calc(100% + var(--space-2)); + right: 0; + width: min(24rem, 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; +} + +.header, +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.header { + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--color-border); +} + +.headerCopy { + min-width: 0; + display: grid; + gap: 0.08rem; +} + +.title { + @include text-label; + color: var(--color-text); +} + +.subtitle, +.sectionLabel, +.itemMeta, +.itemTime { + @include text-caption; + color: var(--color-text-muted); +} + +.headerAction, +.footerAction { + @include text-caption; + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 0; + border: 0; + background: transparent; + color: var(--color-text-muted); + transition: color 160ms var(--easing-standard); +} + +.headerAction:hover, +.headerAction:focus-visible, +.footerAction:hover, +.footerAction:focus-visible { + outline: none; + color: var(--color-text); +} + +.listWrap { + display: grid; + gap: var(--space-3); + max-height: min(24rem, 60vh); + overflow-y: auto; + padding-right: var(--space-1); + margin-right: calc(var(--space-1) * -1); +} + +.stateCard { + display: grid; + justify-items: start; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid color-mix(in srgb, var(--color-border) 18%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-surface) 92%, transparent); +} + +.stateIcon { + width: 2.2rem; + height: 2.2rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: color-mix(in srgb, var(--color-surface-muted) 88%, transparent); + color: var(--color-text); +} + +.stateTitle { + @include text-label; + color: var(--color-text); +} + +.stateCopy { + @include text-caption; + color: var(--color-text-muted); +} + +.section { + display: grid; + gap: var(--space-2); +} + +.section + .section { + padding-top: var(--space-3); + border-top: 1px solid var(--color-border); +} + +.sectionLabel { + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-subtle); +} + +.list { + display: grid; + gap: 0.3rem; +} + +.item { + width: 100%; + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + 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); +} + +.itemUnread { + background: color-mix(in srgb, var(--color-surface) 90%, transparent); + border-color: color-mix(in srgb, var(--color-border) 16%, transparent); +} + +.itemMarker, +.itemMarkerMuted { + width: 0.5rem; + height: 0.5rem; + margin-top: 0.45rem; + border-radius: 999px; + flex-shrink: 0; + background: color-mix(in srgb, var(--color-primary-2) 78%, white 22%); +} + +.itemMarkerMuted { + background: color-mix(in srgb, var(--color-text-subtle) 36%, transparent); +} + +.itemBody { + min-width: 0; + display: grid; + gap: 0.12rem; +} + +.itemTitle { + @include text-label; + color: var(--color-text); +} + +.itemTime { + padding-top: 0.05rem; + white-space: nowrap; + color: var(--color-text-subtle); +} + +.footer { + padding-top: var(--space-3); + border-top: 1px solid var(--color-border); + justify-content: space-between; + flex-wrap: wrap; +} + +@include respond-down(mobile) { + .menu { + width: min(22rem, calc(100vw - (var(--space-3) * 2))); + } + + .item { + grid-template-columns: auto minmax(0, 1fr); + } + + .itemTime { + grid-column: 2; + padding-top: 0; + } + + .footer { + align-items: flex-start; + flex-direction: column; + } + + .footerAction { + padding: var(--space-1) 0; + } + } diff --git a/Frontend/src/components/shell/TopBar/NotificationsMenu.tsx b/Frontend/src/components/shell/TopBar/NotificationsMenu.tsx new file mode 100644 index 0000000..30ef389 --- /dev/null +++ b/Frontend/src/components/shell/TopBar/NotificationsMenu.tsx @@ -0,0 +1,112 @@ +import { For, Show, type JSX } from "solid-js"; +import { Bell, Settings } from "../../../lib/icons"; +import { notificationItems, unreadNotificationCount } from "../data/shell.data"; +import styles from "./NotificationsMenu.module.scss"; + +type NotificationsMenuProps = { + id: string; + menuRef: (element: HTMLDivElement) => void; + onSelect: () => void; +}; + +export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element => { + const unreadItems = notificationItems.filter((item) => item.unread); + const earlierItems = notificationItems.filter((item) => !item.unread); + const hasNotifications = notificationItems.length > 0; + const isCaughtUp = unreadItems.length === 0 && hasNotifications; + + return ( + + ); +}; diff --git a/Frontend/src/components/shell/TopBar/NotificationsNav.module.scss b/Frontend/src/components/shell/TopBar/NotificationsNav.module.scss new file mode 100644 index 0000000..bbfe5ed --- /dev/null +++ b/Frontend/src/components/shell/TopBar/NotificationsNav.module.scss @@ -0,0 +1,5 @@ +.root { + position: relative; + display: inline-flex; + align-items: center; +} diff --git a/Frontend/src/components/shell/TopBar/NotificationsNav.tsx b/Frontend/src/components/shell/TopBar/NotificationsNav.tsx new file mode 100644 index 0000000..8f0baef --- /dev/null +++ b/Frontend/src/components/shell/TopBar/NotificationsNav.tsx @@ -0,0 +1,56 @@ +import { createEffect, createSignal, createUniqueId, onCleanup, type JSX } from "solid-js"; +import { NotificationsButton } from "./NotificationsButton"; +import { NotificationsMenu } from "./NotificationsMenu"; +import styles from "./NotificationsNav.module.scss"; + +export const NotificationsNav = (): 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("[role='menuitem']")?.focus(); + + onCleanup(() => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }); + }); + + return ( +
+ + {isOpen() ? ( + (menuRef = element)} onSelect={closeMenu} /> + ) : null} +
+ ); +}; diff --git a/Frontend/src/components/shell/TopBar/TopBar.tsx b/Frontend/src/components/shell/TopBar/TopBar.tsx index 7b6bd13..f8c39cf 100644 --- a/Frontend/src/components/shell/TopBar/TopBar.tsx +++ b/Frontend/src/components/shell/TopBar/TopBar.tsx @@ -4,6 +4,7 @@ import { For, type JSX } from "solid-js"; import type { Theme } from "../../../theme/runtime"; import { topBarActions } from "../data/shell.data"; import { DepartmentSelector } from "../DepartmentSelector/DepartmentSelector"; +import { NotificationsNav } from "./NotificationsNav"; import { ThemeToggle } from "./ThemeToggle"; import { UserNav } from "./UserNav"; import styles from "./TopBar.module.scss"; @@ -36,6 +37,7 @@ export const TopBar = (props: TopBarProps): JSX.Element => { + diff --git a/Frontend/src/components/shell/data/shell.data.ts b/Frontend/src/components/shell/data/shell.data.ts index 15158a5..e5cd191 100644 --- a/Frontend/src/components/shell/data/shell.data.ts +++ b/Frontend/src/components/shell/data/shell.data.ts @@ -87,6 +87,14 @@ export type TopBarAction = { icon: ShellIcon; }; +export type NotificationItem = { + id: string; + title: string; + contextLabel: string; + timeLabel: string; + unread?: boolean; +}; + export type ProfileMenuAction = { id: string; label: string; @@ -166,9 +174,39 @@ export const serverSidebarItems: readonly SidebarItem[] = [ export const topBarActions: readonly TopBarAction[] = [ { id: "search", label: "Search", icon: Search }, - { id: "inbox", label: "Inbox", icon: Bell }, ] as const; +export const notificationItems: readonly NotificationItem[] = [ + { + id: "comment-design-systems", + title: "New comment on Design Systems", + contextLabel: "Product • Review thread updated", + timeLabel: "2m ago", + unread: true, + }, + { + id: "sprint-platform", + title: "Sprint updated in Platform", + contextLabel: "Engineering • Scope changed", + timeLabel: "15m ago", + unread: true, + }, + { + id: "member-joined", + title: "New member joined Operations", + contextLabel: "Organization Name • Access granted", + timeLabel: "1h ago", + }, + { + id: "daily-summary", + title: "Daily summary is ready", + contextLabel: "General • 8 updates across boards", + timeLabel: "Today, 8:00 AM", + }, +] as const; + +export const unreadNotificationCount = notificationItems.filter((item) => item.unread).length; + export const activeUserProfile: ActiveUserProfile = { name: "Demo Account", email: "demo@moku.work",