Refactor: improve code modularity
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { JSX } from "solid-js";
|
||||
import { Bell } from "../../../lib/icons";
|
||||
import { unreadNotificationCount } from "../../app-shell/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 (
|
||||
<button
|
||||
classList={{
|
||||
[styles.button]: true,
|
||||
[styles.buttonOpen]: props.isOpen,
|
||||
}}
|
||||
type="button"
|
||||
aria-label={`${props.isOpen ? "Close" : "Open"} notifications${unreadLabel}`}
|
||||
title={`${props.isOpen ? "Close" : "Open"} notifications`}
|
||||
aria-haspopup="menu"
|
||||
aria-controls={props.menuId}
|
||||
aria-expanded={props.isOpen}
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
<span class={styles.iconWrap} aria-hidden="true">
|
||||
<Bell size={18} strokeWidth={2} />
|
||||
{hasUnread ? <span class={styles.badge}>{unreadNotificationCount}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
.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) + (var(--space-1) / 2));
|
||||
background: color-mix(in srgb, var(--color-surface-muted) 96%, transparent);
|
||||
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,
|
||||
.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: calc(var(--space-1) / 2);
|
||||
}
|
||||
|
||||
.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: var(--space-1);
|
||||
}
|
||||
|
||||
.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: calc(var(--space-1) / 2);
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
@include text-label;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.itemTime {
|
||||
padding-top: calc(var(--space-1) / 4);
|
||||
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;
|
||||
}
|
||||
|
||||
.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.menuWorkspace {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.menu.menuWorkspace .listWrap {
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.menu.menuWorkspace .item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.menu.menuWorkspace .itemTime {
|
||||
grid-column: 2;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.menu.menuWorkspace .footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu.menuWorkspace .footerAction {
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { For, Show, type JSX } from "solid-js";
|
||||
import { Bell, Settings } from "../../../lib/icons";
|
||||
import { notificationItems, unreadNotificationCount } from "../../app-shell/data/shell.data";
|
||||
import styles from "./NotificationsMenu.module.scss";
|
||||
|
||||
type NotificationsMenuProps = {
|
||||
id: string;
|
||||
menuRef?: (element: HTMLDivElement) => void;
|
||||
onSelect: () => void;
|
||||
variant?: "popover" | "workspace";
|
||||
};
|
||||
|
||||
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;
|
||||
const variant = props.variant ?? "popover";
|
||||
|
||||
return (
|
||||
<div
|
||||
id={props.id}
|
||||
classList={{
|
||||
[styles.menu]: true,
|
||||
[styles.menuWorkspace]: variant === "workspace",
|
||||
}}
|
||||
role="menu"
|
||||
aria-label="Notifications"
|
||||
ref={props.menuRef}
|
||||
data-ui="notifications-menu"
|
||||
data-variant={variant}
|
||||
>
|
||||
<div class={styles.header} data-slot="notifications-header">
|
||||
<div class={styles.headerCopy} data-slot="notifications-header-copy">
|
||||
<strong class={styles.title}>Notifications</strong>
|
||||
<span class={styles.subtitle}>
|
||||
{unreadNotificationCount > 0
|
||||
? `You have ${unreadNotificationCount} unread`
|
||||
: "You’re all caught up"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={unreadNotificationCount > 0}>
|
||||
<button type="button" role="menuitem" class={styles.headerAction} onClick={props.onSelect}>
|
||||
Mark all read
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={styles.listWrap} data-slot="notifications-body">
|
||||
<Show when={!hasNotifications}>
|
||||
<div class={styles.stateCard}>
|
||||
<span class={styles.stateIcon} aria-hidden="true">
|
||||
<Bell size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<strong class={styles.stateTitle}>No notifications yet</strong>
|
||||
<span class={styles.stateCopy}>When activity starts across your workspace, it’ll show up here.</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isCaughtUp}>
|
||||
<div class={styles.stateCard}>
|
||||
<span class={styles.stateIcon} aria-hidden="true">
|
||||
<Bell size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<strong class={styles.stateTitle}>You’re all caught up</strong>
|
||||
<span class={styles.stateCopy}>No unread notifications right now. Earlier activity is still available below.</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={unreadItems.length > 0}>
|
||||
<section class={styles.section} aria-label="Unread notifications" data-slot="notifications-section" data-section-id="unread">
|
||||
<span class={styles.sectionLabel}>Unread</span>
|
||||
<div class={styles.list} data-slot="notifications-list" data-section-id="unread">
|
||||
<For each={unreadItems}>
|
||||
{(item): JSX.Element => (
|
||||
<button type="button" role="menuitem" classList={{ [styles.item]: true, [styles.itemUnread]: true }} data-slot="notification-item" data-state="unread" onClick={props.onSelect}>
|
||||
<span class={styles.itemMarker} aria-hidden="true" />
|
||||
<div class={styles.itemBody}>
|
||||
<span class={styles.itemTitle}>{item.title}</span>
|
||||
<span class={styles.itemMeta}>{item.contextLabel}</span>
|
||||
</div>
|
||||
<span class={styles.itemTime}>{item.timeLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={earlierItems.length > 0}>
|
||||
<section class={styles.section} aria-label="Earlier notifications" data-slot="notifications-section" data-section-id="earlier">
|
||||
<span class={styles.sectionLabel}>Earlier</span>
|
||||
<div class={styles.list} data-slot="notifications-list" data-section-id="earlier">
|
||||
<For each={earlierItems}>
|
||||
{(item): JSX.Element => (
|
||||
<button type="button" role="menuitem" class={styles.item} data-slot="notification-item" data-state="read" onClick={props.onSelect}>
|
||||
<span class={styles.itemMarkerMuted} aria-hidden="true" />
|
||||
<div class={styles.itemBody}>
|
||||
<span class={styles.itemTitle}>{item.title}</span>
|
||||
<span class={styles.itemMeta}>{item.contextLabel}</span>
|
||||
</div>
|
||||
<span class={styles.itemTime}>{item.timeLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={styles.footer} data-slot="notifications-footer">
|
||||
<button type="button" role="menuitem" class={styles.footerAction} onClick={props.onSelect}>
|
||||
<Settings size={16} strokeWidth={2} />
|
||||
<span>Notification settings</span>
|
||||
</button>
|
||||
|
||||
<button type="button" role="menuitem" class={styles.footerAction} onClick={props.onSelect}>
|
||||
<Bell size={16} strokeWidth={2} />
|
||||
<span>View all notifications</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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";
|
||||
|
||||
type NotificationsNavProps = {
|
||||
isMobileViewport: boolean;
|
||||
isMobileWorkspaceOpen: boolean;
|
||||
onToggleMobileWorkspace: VoidFunction;
|
||||
};
|
||||
|
||||
export const NotificationsNav = (props: NotificationsNavProps): JSX.Element => {
|
||||
const menuId = createUniqueId();
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
.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) + (var(--space-1) / 2));
|
||||
background: color-mix(in srgb, var(--color-surface-muted) 96%, transparent);
|
||||
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 {
|
||||
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: calc(var(--space-1) / 2);
|
||||
}
|
||||
|
||||
.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: var(--space-1);
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: calc(var(--control-size-lg) + (var(--space-1) / 2));
|
||||
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: 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;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { For, type JSX } from "solid-js";
|
||||
import { User } from "../../../lib/icons";
|
||||
import { useAppShellData } from "../../app-shell/data/app-shell.context";
|
||||
import { profileMenuSections } from "../../app-shell/data/shell.data";
|
||||
import styles from "./ProfileMenu.module.scss";
|
||||
|
||||
type ProfileMenuProps = {
|
||||
id: string;
|
||||
menuRef?: (element: HTMLDivElement) => void;
|
||||
onSelect: () => void;
|
||||
variant?: "popover" | "workspace";
|
||||
};
|
||||
|
||||
export const ProfileMenu = (props: ProfileMenuProps): JSX.Element => {
|
||||
const variant = props.variant ?? "popover";
|
||||
const appShellData = useAppShellData();
|
||||
const activeUserProfile = () => appShellData.activeUserProfile();
|
||||
|
||||
return (
|
||||
<div
|
||||
id={props.id}
|
||||
classList={{
|
||||
[styles.menu]: true,
|
||||
[styles.menuWorkspace]: variant === "workspace",
|
||||
}}
|
||||
role="menu"
|
||||
aria-label="Profile menu"
|
||||
ref={props.menuRef}
|
||||
data-ui="profile-menu"
|
||||
data-variant={variant}
|
||||
>
|
||||
<div class={styles.summary} data-slot="profile-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>
|
||||
|
||||
<div class={styles.sections} data-slot="profile-sections">
|
||||
<For each={profileMenuSections}>
|
||||
{(section): JSX.Element => (
|
||||
<div class={styles.section} data-slot="profile-section" data-section-id={section.id}>
|
||||
<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",
|
||||
}}
|
||||
data-slot="profile-action"
|
||||
data-action-id={item.id}
|
||||
data-tone={item.tone ?? "default"}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
.toggleButton {
|
||||
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 500ms ease,
|
||||
color 220ms var(--easing-standard),
|
||||
transform 180ms var(--easing-standard);
|
||||
}
|
||||
|
||||
.toggleButton:hover {
|
||||
background: color-mix(in srgb, var(--color-text) 8%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.toggleButton: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);
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
position: relative;
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
}
|
||||
|
||||
.iconLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
transform 1000ms ease,
|
||||
opacity 500ms ease;
|
||||
}
|
||||
|
||||
.moonLayer {
|
||||
transform: rotate(90deg);
|
||||
opacity: 0;
|
||||
|
||||
:global([data-theme="dark"]) & {
|
||||
transform: rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sunLayer {
|
||||
transform: rotate(0deg);
|
||||
opacity: 1;
|
||||
|
||||
:global([data-theme="dark"]) & {
|
||||
transform: rotate(-90deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Path: Frontend/src/components/top-bar/TopBar/ThemeToggle.tsx
|
||||
|
||||
import type { JSX } from "solid-js";
|
||||
import type { Theme } from "../../../helper/themeRuntime";
|
||||
import { Moon, Sun } from "../../../lib/icons";
|
||||
import styles from "./ThemeToggle.module.scss";
|
||||
|
||||
type ThemeToggleProps = {
|
||||
theme: Theme;
|
||||
onToggle: VoidFunction;
|
||||
};
|
||||
|
||||
export const ThemeToggle = (props: ThemeToggleProps): JSX.Element => {
|
||||
return (
|
||||
<button
|
||||
class={styles.toggleButton}
|
||||
type="button"
|
||||
onClick={props.onToggle}
|
||||
aria-label={`Switch to ${props.theme === "dark" ? "light" : "dark"} theme`}
|
||||
title={`Switch to ${props.theme === "dark" ? "light" : "dark"} theme`}
|
||||
>
|
||||
<span class={styles.iconContainer} aria-hidden="true">
|
||||
<span class={`${styles.iconLayer} ${styles.moonLayer}`}>
|
||||
<Moon size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<span class={`${styles.iconLayer} ${styles.sunLayer}`}>
|
||||
<Sun size={18} strokeWidth={2} />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
.topBar {
|
||||
--topbar-control-size: 2.5rem;
|
||||
min-height: 4rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) var(--space-3);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.identity {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@include text-caption;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.controls,
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--topbar-control-size);
|
||||
height: var(--topbar-control-size);
|
||||
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);
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
background: color-mix(in srgb, var(--color-text) 8%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.actionButton: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);
|
||||
}
|
||||
|
||||
@include respond-down(mobile) {
|
||||
.topBar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: var(--space-2) var(--space-3) var(--space-3);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Path: Frontend/src/components/top-bar/TopBar/TopBar.tsx
|
||||
|
||||
import { For, type JSX } from "solid-js";
|
||||
import type { Theme } from "../../../helper/themeRuntime";
|
||||
import { topBarActions } from "../../app-shell/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";
|
||||
|
||||
type TopBarProps = {
|
||||
theme: Theme;
|
||||
onToggleTheme: VoidFunction;
|
||||
isMobileViewport: boolean;
|
||||
isNotificationsOpen: boolean;
|
||||
isProfileOpen: boolean;
|
||||
onToggleNotifications: VoidFunction;
|
||||
onToggleProfile: VoidFunction;
|
||||
};
|
||||
|
||||
export const TopBar = (props: TopBarProps): JSX.Element => {
|
||||
return (
|
||||
<header class={styles.topBar} data-ui="top-bar">
|
||||
<div class={styles.identity} data-slot="top-bar-identity">
|
||||
<span class={styles.eyebrow}>Moku Work</span>
|
||||
<DepartmentSelector />
|
||||
</div>
|
||||
|
||||
<div class={styles.controls} data-slot="top-bar-controls">
|
||||
<div class={styles.actions} data-slot="top-bar-actions">
|
||||
<For each={topBarActions}>
|
||||
{(item): JSX.Element => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button class={styles.actionButton} type="button" aria-label={item.label} title={item.label} data-slot="top-bar-action" data-action-id={item.id}>
|
||||
<Icon size={18} strokeWidth={2} />
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<NotificationsNav
|
||||
isMobileViewport={props.isMobileViewport}
|
||||
isMobileWorkspaceOpen={props.isNotificationsOpen}
|
||||
onToggleMobileWorkspace={props.onToggleNotifications}
|
||||
/>
|
||||
<ThemeToggle theme={props.theme} onToggle={props.onToggleTheme} />
|
||||
<UserNav
|
||||
isMobileViewport={props.isMobileViewport}
|
||||
isMobileWorkspaceOpen={props.isProfileOpen}
|
||||
onToggleMobileWorkspace={props.onToggleProfile}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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";
|
||||
|
||||
type UserNavProps = {
|
||||
isMobileViewport: boolean;
|
||||
isMobileWorkspaceOpen: boolean;
|
||||
onToggleMobileWorkspace: VoidFunction;
|
||||
};
|
||||
|
||||
export const UserNav = (props: UserNavProps): JSX.Element => {
|
||||
const menuId = createUniqueId();
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
.userButton {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
margin-left: var(--space-1);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 180ms var(--easing-standard),
|
||||
color 220ms var(--easing-standard);
|
||||
}
|
||||
|
||||
.userButton:hover {
|
||||
transform: scale(1.05);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.userButtonOpen {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.userButton:hover .spinContainer,
|
||||
.userButtonOpen .spinContainer {
|
||||
animation-play-state: running;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.userButton:focus-visible {
|
||||
outline: none;
|
||||
color: var(--color-text);
|
||||
box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--color-accent-strong) 28%, transparent);
|
||||
}
|
||||
|
||||
.spinContainer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.72;
|
||||
transition: opacity 220ms var(--easing-standard);
|
||||
animation: spin-reverse 1.5s ease-in-out infinite reverse;
|
||||
animation-play-state: paused;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spinRing {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
conic-gradient(
|
||||
from 0deg,
|
||||
transparent 0deg 28deg,
|
||||
var(--color-primary-1) 28deg 118deg,
|
||||
transparent 118deg 148deg,
|
||||
var(--color-primary-2) 148deg 238deg,
|
||||
transparent 238deg 268deg,
|
||||
var(--color-primary-3) 268deg 358deg,
|
||||
transparent 358deg 360deg
|
||||
);
|
||||
mask: radial-gradient(circle, transparent 63%, black 66%);
|
||||
-webkit-mask: radial-gradient(circle, transparent 63%, black 66%);
|
||||
animation: spin-forward 14s linear infinite;
|
||||
}
|
||||
|
||||
.userCore {
|
||||
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-muted);
|
||||
}
|
||||
|
||||
@keyframes spin-forward {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-reverse {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Path: Frontend/src/components/top-bar/TopBar/UserNavButton.tsx
|
||||
|
||||
import type { JSX } from "solid-js";
|
||||
import { User } from "../../../lib/icons";
|
||||
import styles from "./UserNavButton.module.scss";
|
||||
|
||||
type UserNavButtonProps = {
|
||||
isOpen: boolean;
|
||||
menuId: string;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
export const UserNavButton = (props: UserNavButtonProps): JSX.Element => {
|
||||
return (
|
||||
<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>
|
||||
|
||||
<span class={styles.userCore} aria-hidden="true">
|
||||
<User size={16} strokeWidth={2.2} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user