Feat: Add responsive workspace shell

This commit is contained in:
MangoPig
2026-06-17 10:52:14 +01:00
parent 5d86a5124b
commit 85bf971547
24 changed files with 1153 additions and 255 deletions

View File

@@ -7,11 +7,34 @@
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: calc(var(--radius-lg) + 0.1rem);
border-radius: calc(var(--radius-lg) + (var(--space-1) / 2));
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;
box-shadow: var(--shadow-strong);
backdrop-filter: blur(var(--blur-overlay));
z-index: var(--z-dropdown);
}
.menu.menuWorkspace {
position: static;
top: auto;
right: auto;
left: auto;
bottom: auto;
width: 100%;
max-width: none;
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--space-4);
padding: var(--space-4);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
z-index: auto;
overflow: hidden;
}
.header,
@@ -30,7 +53,7 @@
.headerCopy {
min-width: 0;
display: grid;
gap: 0.08rem;
gap: calc(var(--space-1) / 2);
}
.title {
@@ -125,7 +148,7 @@
.list {
display: grid;
gap: 0.3rem;
gap: var(--space-1);
}
.item {
@@ -181,7 +204,7 @@
.itemBody {
min-width: 0;
display: grid;
gap: 0.12rem;
gap: calc(var(--space-1) / 2);
}
.itemTitle {
@@ -190,7 +213,7 @@
}
.itemTime {
padding-top: 0.05rem;
padding-top: calc(var(--space-1) / 4);
white-space: nowrap;
color: var(--color-text-subtle);
}
@@ -202,26 +225,47 @@
flex-wrap: wrap;
}
.menu.menuWorkspace .listWrap {
min-height: 0;
max-height: none;
height: 100%;
padding-right: 0;
margin-right: 0;
}
.menu.menuWorkspace .header,
.menu.menuWorkspace .footer {
padding-left: 0;
padding-right: 0;
}
@include respond-down(mobile) {
.menu {
width: min(22rem, calc(100vw - (var(--space-3) * 2)));
.menu.menuWorkspace {
height: 100%;
min-height: 0;
padding: var(--space-5);
}
.item {
.menu.menuWorkspace .listWrap {
min-height: 0;
max-height: none;
}
.menu.menuWorkspace .item {
grid-template-columns: auto minmax(0, 1fr);
}
.itemTime {
.menu.menuWorkspace .itemTime {
grid-column: 2;
padding-top: 0;
}
.footer {
.menu.menuWorkspace .footer {
align-items: flex-start;
flex-direction: column;
}
.footerAction {
.menu.menuWorkspace .footerAction {
padding: var(--space-1) 0;
}
}
}

View File

@@ -5,8 +5,9 @@ import styles from "./NotificationsMenu.module.scss";
type NotificationsMenuProps = {
id: string;
menuRef: (element: HTMLDivElement) => void;
menuRef?: (element: HTMLDivElement) => void;
onSelect: () => void;
variant?: "popover" | "workspace";
};
export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element => {
@@ -14,9 +15,19 @@ export const NotificationsMenu = (props: NotificationsMenuProps): JSX.Element =>
const earlierItems = notificationItems.filter((item) => !item.unread);
const hasNotifications = notificationItems.length > 0;
const isCaughtUp = unreadItems.length === 0 && hasNotifications;
const variant = props.variant ?? "popover";
return (
<div id={props.id} class={styles.menu} role="menu" aria-label="Notifications" ref={props.menuRef}>
<div
id={props.id}
classList={{
[styles.menu]: true,
[styles.menuWorkspace]: variant === "workspace",
}}
role="menu"
aria-label="Notifications"
ref={props.menuRef}
>
<div class={styles.header}>
<div class={styles.headerCopy}>
<strong class={styles.title}>Notifications</strong>

View File

@@ -1,56 +1,38 @@
import { createEffect, createSignal, createUniqueId, onCleanup, type JSX } from "solid-js";
import { createUniqueId, Show, type JSX } from "solid-js";
import { NotificationsButton } from "./NotificationsButton";
import { NotificationsMenu } from "./NotificationsMenu";
import { createDesktopMenuController } from "./createDesktopMenuController";
import styles from "./NotificationsNav.module.scss";
export const NotificationsNav = (): JSX.Element => {
const [isOpen, setIsOpen] = createSignal(false);
type NotificationsNavProps = {
isMobileViewport: boolean;
isMobileWorkspaceOpen: boolean;
onToggleMobileWorkspace: VoidFunction;
};
export const NotificationsNav = (props: NotificationsNavProps): JSX.Element => {
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}>
<NotificationsButton isOpen={isOpen()} menuId={menuId} onToggle={toggleMenu} />
{isOpen() ? (
<NotificationsMenu id={menuId} menuRef={(element) => (menuRef = element)} onSelect={closeMenu} />
) : null}
<Show
when={props.isMobileViewport}
fallback={<DesktopNotificationsNav />}
>
<div class={styles.root}>
<NotificationsButton isOpen={props.isMobileWorkspaceOpen} menuId={menuId} onToggle={props.onToggleMobileWorkspace} />
</div>
</Show>
);
};
const DesktopNotificationsNav = (): JSX.Element => {
const controller = createDesktopMenuController();
const menuId = createUniqueId();
return (
<div class={styles.root} ref={controller.setRootRef}>
<NotificationsButton isOpen={controller.isOpen()} menuId={menuId} onToggle={controller.toggleMenu} />
{controller.isOpen() ? <NotificationsMenu id={menuId} menuRef={controller.setMenuRef} onSelect={controller.closeMenu} /> : null}
</div>
);
};

View File

@@ -7,11 +7,39 @@
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: calc(var(--radius-lg) + 0.1rem);
border-radius: calc(var(--radius-lg) + (var(--space-1) / 2));
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;
box-shadow: var(--shadow-strong);
backdrop-filter: blur(var(--blur-overlay));
z-index: var(--z-dropdown);
}
.menu.menuWorkspace {
position: static;
top: auto;
right: auto;
left: auto;
bottom: auto;
width: 100%;
max-width: none;
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-4);
padding: var(--space-4);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
z-index: auto;
overflow: hidden;
}
.sections {
display: grid;
gap: var(--space-3);
}
.summary {
@@ -71,7 +99,7 @@
.summaryCopy {
min-width: 0;
display: grid;
gap: 0.08rem;
gap: calc(var(--space-1) / 2);
}
.name,
@@ -97,7 +125,7 @@
.section {
display: grid;
gap: 0.2rem;
gap: var(--space-1);
}
.section + .section {
@@ -108,7 +136,7 @@
.item {
width: 100%;
min-width: 0;
min-height: 2.65rem;
min-height: calc(var(--control-size-lg) + (var(--space-1) / 2));
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
@@ -148,8 +176,8 @@
}
.itemIcon {
width: 1.9rem;
height: 1.9rem;
width: calc(var(--control-size-lg) - var(--space-2));
height: calc(var(--control-size-lg) - var(--space-2));
display: inline-flex;
align-items: center;
justify-content: center;
@@ -158,8 +186,15 @@
color: currentColor;
}
@include respond-down(mobile) {
.menu {
width: min(20rem, calc(100vw - (var(--space-3) * 2)));
}
.menu.menuWorkspace .sections {
min-height: 0;
height: 100%;
align-content: start;
overflow-y: auto;
}
.menu.menuWorkspace .summary,
.menu.menuWorkspace .sections {
padding-left: 0;
padding-right: 0;
}

View File

@@ -5,13 +5,25 @@ import styles from "./ProfileMenu.module.scss";
type ProfileMenuProps = {
id: string;
menuRef: (element: HTMLDivElement) => void;
menuRef?: (element: HTMLDivElement) => void;
onSelect: () => void;
variant?: "popover" | "workspace";
};
export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
const variant = props.variant ?? "popover";
return (
<div id={props.id} class={styles.menu} role="menu" aria-label="Profile menu" ref={props.menuRef}>
<div
id={props.id}
classList={{
[styles.menu]: true,
[styles.menuWorkspace]: variant === "workspace",
}}
role="menu"
aria-label="Profile menu"
ref={props.menuRef}
>
<div class={styles.summary}>
<div class={styles.avatar} aria-hidden="true">
<span class={styles.avatarRing} />
@@ -28,34 +40,36 @@ export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
</div>
</div>
<For each={profileMenuSections}>
{(section): JSX.Element => (
<div class={styles.section}>
<For each={section.items}>
{(item): JSX.Element => {
const Icon = item.icon;
<div class={styles.sections}>
<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>
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>
</div>
);
};

View File

@@ -12,6 +12,11 @@ import styles from "./TopBar.module.scss";
type TopBarProps = {
theme: Theme;
onToggleTheme: VoidFunction;
isMobileViewport: boolean;
isNotificationsOpen: boolean;
isProfileOpen: boolean;
onToggleNotifications: VoidFunction;
onToggleProfile: VoidFunction;
};
export const TopBar = (props: TopBarProps): JSX.Element => {
@@ -37,9 +42,17 @@ export const TopBar = (props: TopBarProps): JSX.Element => {
</For>
</div>
<NotificationsNav />
<NotificationsNav
isMobileViewport={props.isMobileViewport}
isMobileWorkspaceOpen={props.isNotificationsOpen}
onToggleMobileWorkspace={props.onToggleNotifications}
/>
<ThemeToggle theme={props.theme} onToggle={props.onToggleTheme} />
<UserNav />
<UserNav
isMobileViewport={props.isMobileViewport}
isMobileWorkspaceOpen={props.isProfileOpen}
onToggleMobileWorkspace={props.onToggleProfile}
/>
</div>
</header>
);

View File

@@ -1,54 +1,38 @@
import { createEffect, createSignal, createUniqueId, onCleanup, type JSX } from "solid-js";
import { createUniqueId, Show, type JSX } from "solid-js";
import { ProfileMenu } from "./ProfileMenu";
import { UserNavButton } from "./UserNavButton";
import { createDesktopMenuController } from "./createDesktopMenuController";
import styles from "./UserNav.module.scss";
export const UserNav = (): JSX.Element => {
const [isOpen, setIsOpen] = createSignal(false);
type UserNavProps = {
isMobileViewport: boolean;
isMobileWorkspaceOpen: boolean;
onToggleMobileWorkspace: VoidFunction;
};
export const UserNav = (props: UserNavProps): JSX.Element => {
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}
<Show
when={props.isMobileViewport}
fallback={<DesktopUserNav />}
>
<div class={styles.root}>
<UserNavButton isOpen={props.isMobileWorkspaceOpen} menuId={menuId} onToggle={props.onToggleMobileWorkspace} />
</div>
</Show>
);
};
const DesktopUserNav = (): JSX.Element => {
const controller = createDesktopMenuController();
const menuId = createUniqueId();
return (
<div class={styles.root} ref={controller.setRootRef}>
<UserNavButton isOpen={controller.isOpen()} menuId={menuId} onToggle={controller.toggleMenu} />
{controller.isOpen() ? <ProfileMenu id={menuId} menuRef={controller.setMenuRef} onSelect={controller.closeMenu} /> : null}
</div>
);
};

View File

@@ -0,0 +1,72 @@
import { createEffect, createSignal, onCleanup } from "solid-js";
type DesktopMenuController = {
isOpen: () => boolean;
rootRef: HTMLDivElement | undefined;
menuRef: HTMLDivElement | undefined;
setRootRef: (element: HTMLDivElement) => void;
setMenuRef: (element: HTMLDivElement) => void;
closeMenu: VoidFunction;
toggleMenu: VoidFunction;
};
// Shared desktop popover behavior for top-bar menus.
export const createDesktopMenuController = (): DesktopMenuController => {
const [isOpen, setIsOpen] = createSignal(false);
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 {
isOpen,
get rootRef() {
return rootRef;
},
get menuRef() {
return menuRef;
},
setRootRef: (element: HTMLDivElement): void => {
rootRef = element;
},
setMenuRef: (element: HTMLDivElement): void => {
menuRef = element;
},
closeMenu,
toggleMenu,
};
};