Merge branch 'feat/new-side-nav'

* feat/new-side-nav: (61 commits)
  feat: make scrollbars a bit more subtle
  feat(sideNav): move drag handle right of scrollbars
  feat(sideNav): make text color work better
  feat(sideNav): make shepherd kinda work and throw out more layout stuff
  feat(sideNav): remove side nav layout store stuff
  feat(sideNav): make mobile nav work again
  feat(sideNav): remove grab cursor from Today and Inbox entries
  feat(sideNav): add escape key to unfocus nav and focus first task
  feat(sideNav): fix colors and make focus shortcut working
  feat(sideNav): prepare focus sidenav
  feat(sideNav): make backdrop work
  feat(sideNav): always close mobile nav when item was clicked
  feat(sideNav): improve mobile
  feat(sideNav): make mobile nav work
  feat(sideNav): move certain nav parts to the bottom
  feat(sideNav): bring back project visibility menu
  feat(sideNav): improve styling
  refactor(sideNav): remove logs
  feat(sideNav): make icons work again
  feat(sideNav): make keyboard nav work
  ...

# Conflicts:
#	src/app/core-ui/side-nav/side-nav-item/side-nav-item.component.scss
#	src/app/core-ui/side-nav/side-nav.component.scss
#	src/styles/_css-variables.scss
This commit is contained in:
Johannes Millan 2025-09-09 19:30:02 +02:00
commit 4f0154e19e
55 changed files with 2799 additions and 1268 deletions

View file

@ -1,3 +1,3 @@
export const cssSelectors = {
SIDENAV: 'side-nav',
SIDENAV: 'magic-side-nav',
};

View file

@ -25,18 +25,13 @@
@if (isShowFocusOverlay()) {
<focus-mode-overlay @warp></focus-mode-overlay>
} @else {
<mat-sidenav-container [dir]="isRTL ? 'rtl' : 'ltr'">
<mat-sidenav
(closedStart)="layoutService.hideSideNav()"
[mode]="layoutService.isNavOver() ? 'over' : 'side'"
[opened]="layoutService.isShowSideNav()"
[disableClose]="layoutService.isNavAlwaysVisible()"
position="start"
>
<side-nav></side-nav>
@if (workContextService.isActiveWorkContextProject$ | async) {}
</mat-sidenav>
<mat-sidenav-content>
<div
class="app-container"
[dir]="isRTL ? 'rtl' : 'ltr'"
>
<magic-side-nav [activeWorkContextId]="getActiveWorkContextId()"> </magic-side-nav>
<main class="main-content">
<header
[class.isNotScrolled]="!layoutService.isScrolled()"
class="header-wrapper"
@ -56,8 +51,8 @@
</div>
</right-panel>
<global-progress-bar></global-progress-bar>
</mat-sidenav-content>
</mat-sidenav-container>
</main>
</div>
}
<!-- -->
}

View file

@ -6,6 +6,38 @@
height: 100%;
}
.app-container {
display: flex;
height: 100vh;
min-height: 100vh;
// mobile viewport bug fix
min-height: -webkit-fill-available;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0; // Prevent flex item from growing beyond container
overflow: hidden;
}
// Override magic-side-nav styles for flex layout integration
// Use desktop layout overrides only on larger screens
@media (min-width: 768px) {
magic-side-nav {
::ng-deep .nav-sidebar {
position: relative !important;
top: auto !important;
left: auto !important;
bottom: auto !important;
height: 100vh !important;
min-height: -webkit-fill-available !important;
z-index: auto !important;
}
}
}
.header-wrapper {
position: relative;
z-index: var(--z-main-header-wrapper);
@ -96,7 +128,7 @@ mat-sidenav-container {
mat-sidenav ::ng-deep .mat-drawer-inner-container {
@include scrollYImportant();
background: var(--sidebar-bg);
background: var(--sidenav-bg);
}
mat-sidenav {

View file

@ -19,7 +19,7 @@ import { GlobalConfigService } from './features/config/global-config.service';
import { LayoutService } from './core-ui/layout/layout.service';
import { IPC } from '../../electron/shared-with-frontend/ipc-events.const';
import { SnackService } from './core/snack/snack.service';
import { IS_ELECTRON, LanguageCode } from './app.constants';
import { BodyClass, IS_ELECTRON, LanguageCode } from './app.constants';
import { expandAnimation } from './ui/animations/expand.ani';
import { warpRouteAnimation } from './ui/animations/warp-route';
import { combineLatest, Observable, Subscription } from 'rxjs';
@ -45,13 +45,8 @@ import { IS_MOBILE } from './util/is-mobile';
import { warpAnimation, warpInAnimation } from './ui/animations/warp.ani';
import { GlobalConfigState } from './features/config/global-config.model';
import { AddTaskBarComponent } from './features/tasks/add-task-bar/add-task-bar.component';
import {
MatSidenav,
MatSidenavContainer,
MatSidenavContent,
} from '@angular/material/sidenav';
import { Dir } from '@angular/cdk/bidi';
import { SideNavComponent } from './core-ui/side-nav/side-nav.component';
import { MagicSideNavComponent } from './core-ui/magic-side-nav/magic-side-nav.component';
import { MainHeaderComponent } from './core-ui/main-header/main-header.component';
import { BannerComponent } from './core/banner/banner/banner.component';
import { GlobalProgressBarComponent } from './core-ui/global-progress-bar/global-progress-bar.component';
@ -109,11 +104,8 @@ const productivityTip: string[] = w.productivityTips && w.productivityTips[w.ran
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AddTaskBarComponent,
MatSidenavContainer,
Dir,
MatSidenav,
SideNavComponent,
MatSidenavContent,
MagicSideNavComponent,
MainHeaderComponent,
BannerComponent,
RightPanelComponent,
@ -195,6 +187,11 @@ export class AppComponent implements OnDestroy, AfterViewInit {
initialValue: false,
});
private readonly _activeWorkContextId = toSignal(
this.workContextService.activeWorkContextId$,
{ initialValue: null },
);
private _subs: Subscription = new Subscription();
private _intervalTimer?: NodeJS.Timeout;
@ -211,6 +208,17 @@ export class AppComponent implements OnDestroy, AfterViewInit {
document.dir = this.isRTL ? 'rtl' : 'ltr';
});
// Add/remove hasBgImage class to body when background image changes
effect(() => {
const bgImage = this.globalThemeService.backgroundImg();
const bodyEl = document.body;
if (bgImage) {
bodyEl.classList.add(BodyClass.hasBgImage);
} else {
bodyEl.classList.remove(BodyClass.hasBgImage);
}
});
this._subs.add(
this._activatedRoute.queryParams.subscribe((params) => {
if (!!params.focusItem) {
@ -435,6 +443,10 @@ export class AppComponent implements OnDestroy, AfterViewInit {
return outlet.activatedRouteData.page || 'one';
}
getActiveWorkContextId(): string | null {
return this._activeWorkContextId();
}
changeBackgroundFromUnsplash(): void {
const dialogRef = this._matDialog.open(DialogUnsplashPickerComponent, {
width: '900px',

View file

@ -114,6 +114,7 @@ export enum BodyClass {
isEnabledBackgroundGradient = 'isEnabledBackgroundGradient',
isDisableAnimations = 'isDisableAnimations',
isDataImportInProgress = 'isDataImportInProgress',
hasBgImage = 'hasBgImage',
isAndroidKeyboardShown = 'isAndroidKeyboardShown',
isAndroidKeyboardHidden = 'isAndroidKeyboardHidden',

View file

@ -3,34 +3,28 @@ import {
hideAddTaskBar,
hideIssuePanel,
hideNonTaskSidePanelContent,
hideSideNav,
hideTaskViewCustomizerPanel,
showAddTaskBar,
toggleIssuePanel,
toggleShowNotes,
toggleSideNav,
toggleTaskViewCustomizerPanel,
} from './store/layout.actions';
import { merge, Observable } from 'rxjs';
import { Observable } from 'rxjs';
import { select, Store } from '@ngrx/store';
import {
LayoutState,
selectIsShowAddTaskBar,
selectIsShowIssuePanel,
selectIsShowNotes,
selectIsShowSideNav,
selectIsShowTaskViewCustomizerPanel,
} from './store/layout.reducer';
import { filter, map, startWith } from 'rxjs/operators';
import { BreakpointObserver } from '@angular/cdk/layout';
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
import { WorkContextService } from '../../features/work-context/work-context.service';
import { selectMiscConfig } from '../../features/config/store/global-config.reducer';
import { NavigationEnd, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
const NAV_ALWAYS_VISIBLE = 1200;
const NAV_OVER_RIGHT_PANEL_NEXT = 800;
const BOTH_OVER = 720;
const NAV_ALWAYS_VISIBLE = 600;
const RIGHT_PANEL_OVER = 720;
const VERY_BIG_SCREEN = NAV_ALWAYS_VISIBLE;
@Injectable({
@ -39,7 +33,6 @@ const VERY_BIG_SCREEN = NAV_ALWAYS_VISIBLE;
export class LayoutService {
private _store$ = inject<Store<LayoutState>>(Store);
private _router = inject(Router);
private _workContextService = inject(WorkContextService);
private _breakPointObserver = inject(BreakpointObserver);
private _previouslyFocusedElement: HTMLElement | null = null;
@ -47,9 +40,6 @@ export class LayoutService {
readonly isShowAddTaskBar$: Observable<boolean> = this._store$.pipe(
select(selectIsShowAddTaskBar),
);
readonly isShowSideNav$: Observable<boolean> = this._store$.pipe(
select(selectIsShowSideNav),
);
readonly isShowIssuePanel$: Observable<boolean> = this._store$.pipe(
select(selectIsShowIssuePanel),
);
@ -58,23 +48,16 @@ export class LayoutService {
readonly isScrolled = signal<boolean>(false);
readonly isShowAddTaskBar = toSignal(this.isShowAddTaskBar$, { initialValue: false });
readonly isNavAlwaysVisible = toSignal(
readonly isMobileNav = toSignal(
this._breakPointObserver
.observe([`(min-width: ${NAV_ALWAYS_VISIBLE}px)`])
.pipe(map((result) => result.matches)),
{ initialValue: false },
);
readonly isRightPanelNextNavOver = toSignal(
this._breakPointObserver
.observe([`(min-width: ${NAV_OVER_RIGHT_PANEL_NEXT}px)`])
.pipe(map((result) => result.matches)),
{ initialValue: false },
);
readonly isRightPanelOver = toSignal(
this._breakPointObserver
.observe([`(min-width: ${BOTH_OVER}px)`])
.observe([`(min-width: ${RIGHT_PANEL_OVER}px)`])
.pipe(map((result) => !result.matches)),
{ initialValue: false },
);
@ -115,29 +98,6 @@ export class LayoutService {
return url.includes('/active/') || url.includes('/tag/') || url.includes('/project/');
}
// Convert misc config to signal
private readonly _miscConfig = toSignal(this._store$.select(selectMiscConfig), {
initialValue: undefined,
});
// Computed signal for nav over state
readonly isNavOver = computed(() => {
const miscCfg = this._miscConfig();
if (miscCfg?.isUseMinimalNav) {
return false;
}
return !this.isRightPanelNextNavOver();
});
private readonly _isShowSideNav = toSignal(this.isShowSideNav$, {
initialValue: false,
});
readonly isShowSideNav = computed(() => {
const isShow = this._isShowSideNav();
return isShow || this.isNavAlwaysVisible();
});
readonly isShowNotes = toSignal(this._store$.pipe(select(selectIsShowNotes)), {
initialValue: false,
});
@ -149,19 +109,6 @@ export class LayoutService {
readonly isShowIssuePanel = toSignal(this.isShowIssuePanel$, { initialValue: false });
// Subscribe to navigation events to hide sidenav
constructor() {
// Only hide sidenav on actual navigation, not on initial load
merge(
this._router.events.pipe(filter((ev) => ev instanceof NavigationStart)),
this._workContextService.onWorkContextChange$,
).subscribe(() => {
if (this.isNavOver() && this._isShowSideNav()) {
this.hideSideNav();
}
});
}
showAddTaskBar(): void {
// Store currently focused element if it's a task
const activeElement = document.activeElement as HTMLElement;
@ -187,14 +134,6 @@ export class LayoutService {
}
}
toggleSideNav(): void {
this._store$.dispatch(toggleSideNav());
}
hideSideNav(): void {
this._store$.dispatch(hideSideNav());
}
toggleNotes(): void {
this._store$.dispatch(toggleShowNotes());
}

View file

@ -16,10 +16,6 @@ export const showAddTaskBar = createAction('[Layout] Show AddTaskBar');
export const hideAddTaskBar = createAction('[Layout] Hide AddTaskBar');
export const hideSideNav = createAction('[Layout] Hide SideBar');
export const toggleSideNav = createAction('[Layout] Toggle SideBar');
export const toggleShowNotes = createAction('[Layout] ToggleShow Notes');
export const hideNonTaskSidePanelContent = createAction(

View file

@ -2,13 +2,11 @@ import {
hideAddTaskBar,
hideNonTaskSidePanelContent,
hidePluginPanel,
hideSideNav,
showAddTaskBar,
showPluginPanel,
toggleIssuePanel,
togglePluginPanel,
toggleShowNotes,
toggleSideNav,
toggleTaskViewCustomizerPanel,
} from './layout.actions';
import {
@ -25,7 +23,6 @@ export interface LayoutState {
isShowAddTaskBar: boolean;
isShowNotes: boolean;
isShowIssuePanel: boolean;
isShowSideNav: boolean;
isShowCelebrate: boolean;
isShowTaskViewCustomizerPanel: boolean;
isShowPluginPanel: boolean;
@ -34,7 +31,6 @@ export interface LayoutState {
export const INITIAL_LAYOUT_STATE: LayoutState = {
isShowAddTaskBar: false,
isShowSideNav: false,
isShowNotes: false,
isShowIssuePanel: false,
isShowCelebrate: false,
@ -51,11 +47,6 @@ export const selectIsShowAddTaskBar = createSelector(
(state) => state.isShowAddTaskBar,
);
export const selectIsShowSideNav = createSelector(
selectLayoutFeatureState,
(state) => state.isShowSideNav,
);
export const selectIsShowNotes = createSelector(
selectLayoutFeatureState,
(state) => state.isShowNotes,
@ -101,10 +92,6 @@ const _reducer = createReducer<LayoutState>(
on(hideAddTaskBar, (state) => ({ ...state, isShowAddTaskBar: false })),
on(hideSideNav, (state) => ({ ...state, isShowSideNav: false })),
on(toggleSideNav, (state) => ({ ...state, isShowSideNav: !state.isShowSideNav })),
on(toggleShowNotes, (state) => ({
...state,
...ALL_PANEL_CONTENT_HIDDEN,

View file

@ -0,0 +1,430 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { MatDialog } from '@angular/material/dialog';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { first } from 'rxjs/operators';
import { WorkContextType } from '../../features/work-context/work-context.model';
import { WorkContextService } from '../../features/work-context/work-context.service';
import { TagService } from '../../features/tag/tag.service';
import { ProjectService } from '../../features/project/project.service';
import { ShepherdService } from '../../features/shepherd/shepherd.service';
import { TourId } from '../../features/shepherd/shepherd-steps.const';
import { T } from '../../t.const';
import { LS } from '../../core/persistence/storage-keys.const';
import { DialogCreateProjectComponent } from '../../features/project/dialogs/create-project/dialog-create-project.component';
import { getGithubErrorUrl } from '../../core/error-handler/global-error-handler.util';
import {
selectAllProjectsExceptInbox,
selectUnarchivedHiddenProjectIds,
selectUnarchivedVisibleProjects,
} from '../../features/project/store/project.selectors';
import { toggleHideFromMenu } from '../../features/project/store/project.actions';
import { NavConfig, NavItem, NavWorkContextItem } from './magic-side-nav.model';
import { TODAY_TAG } from '../../features/tag/tag.const';
@Injectable({
providedIn: 'root',
})
export class MagicNavConfigService {
private readonly _workContextService = inject(WorkContextService);
private readonly _tagService = inject(TagService);
private readonly _projectService = inject(ProjectService);
private readonly _shepherdService = inject(ShepherdService);
private readonly _matDialog = inject(MatDialog);
private readonly _store = inject(Store);
// Simple state signals
private readonly _isProjectsExpanded = signal(
this._getStoredState(LS.IS_PROJECT_LIST_EXPANDED),
);
private readonly _isTagsExpanded = signal(
this._getStoredState(LS.IS_TAG_LIST_EXPANDED),
);
// Data signals
private readonly _mainWorkContext = toSignal(
this._workContextService.mainWorkContext$,
{ initialValue: null },
);
private readonly _inboxContext = toSignal(this._workContextService.inboxWorkContext$, {
initialValue: null,
});
private readonly _visibleProjects = toSignal(
this._store.select(selectUnarchivedVisibleProjects),
{ initialValue: [] },
);
private readonly _allProjectsExceptInbox = toSignal(
this._store.select(selectAllProjectsExceptInbox),
{ initialValue: [] },
);
private readonly _tags = toSignal(this._tagService.tagsNoMyDayAndNoList$, {
initialValue: [],
});
private readonly _activeWorkContextId = toSignal(
this._workContextService.activeWorkContextId$,
{ initialValue: null },
);
// Main navigation configuration
readonly navConfig = computed<NavConfig>(() => ({
items: [
// Work Context Items
...this._buildWorkContextItems(),
// Separator
// Main Routes
{
type: 'route',
id: 'schedule',
label: T.MH.SCHEDULE,
icon: 'early_on',
svgIcon: 'early_on',
route: '/schedule',
},
{
type: 'route',
id: 'planner',
label: T.MH.PLANNER,
icon: 'edit_calendar',
route: '/planner',
},
{
type: 'route',
id: 'boards',
label: T.MH.BOARDS,
icon: 'grid_view',
route: '/boards',
},
// Separator
{ type: 'separator', id: 'sep-2' },
// Projects Section
{
type: 'group',
id: 'projects',
label: T.MH.PROJECTS,
icon: 'expand_more',
children: this._buildProjectItems(),
action: () => this._toggleProjectsExpanded(),
additionalButtons: [
{
id: 'project-visibility',
icon: 'visibility',
tooltip: 'Show/Hide Projects',
action: () => this._openProjectVisibilityMenu(),
},
{
id: 'add-project',
icon: 'add',
tooltip: T.MH.CREATE_PROJECT,
action: () => this._openCreateProject(),
},
],
},
// Tags Section
{
type: 'group',
id: 'tags',
label: T.MH.TAGS,
icon: 'expand_more',
children: this._buildTagItems(),
action: () => this._toggleTagsExpanded(),
},
// Separator
{ type: 'separator', id: 'sep-3', mtAuto: true },
// App Section
{
type: 'route',
id: 'search',
label: T.MH.SEARCH,
icon: 'search',
route: '/search',
},
{
type: 'route',
id: 'scheduled-list',
label: T.MH.ALL_PLANNED_LIST,
icon: 'list',
route: '/scheduled-list',
},
// Help Menu (rendered as mat-menu)
{
type: 'menu',
id: 'help',
label: T.MH.HELP,
icon: 'help_center',
children: [
{
type: 'href',
id: 'help-online',
label: T.MH.HM.GET_HELP_ONLINE,
icon: 'help_center',
href: 'https://github.com/johannesjo/super-productivity/blob/master/README.md#question-how-to-use-it',
},
{
type: 'action',
id: 'help-report',
label: T.MH.HM.REPORT_A_PROBLEM,
icon: 'bug_report',
action: () => this._openBugReport(),
},
{
type: 'href',
id: 'help-contribute',
label: T.MH.HM.CONTRIBUTE,
icon: 'volunteer_activism',
href: 'https://github.com/johannesjo/super-productivity/blob/master/CONTRIBUTING.md',
},
{
type: 'href',
id: 'help-reddit',
label: T.MH.HM.REDDIT_COMMUNITY,
icon: 'forum',
href: 'https://www.reddit.com/r/superProductivity/',
},
{
type: 'action',
id: 'tour-welcome',
label: T.MH.HM.START_WELCOME,
icon: 'directions',
action: () => this._startTour(TourId.Welcome),
},
{
type: 'action',
id: 'tour-keyboard',
label: T.MH.HM.KEYBOARD,
icon: 'directions',
action: () => this._startTour(TourId.KeyboardNav),
},
{
type: 'action',
id: 'tour-sync',
label: T.MH.HM.SYNC,
icon: 'directions',
action: () => this._startTour(TourId.Sync),
},
{
type: 'action',
id: 'tour-calendars',
label: T.MH.HM.CALENDARS,
icon: 'directions',
action: () => this._startTour(TourId.IssueProviders),
},
],
},
{
type: 'route',
id: 'settings',
label: T.MH.SETTINGS,
icon: 'settings',
route: '/config',
},
],
fullModeByDefault: true,
showLabels: true,
mobileBreakpoint: 600,
position: 'left',
theme: 'light',
resizable: true,
minWidth: 190,
maxWidth: 400,
defaultWidth: 260,
collapseThreshold: 150,
expandThreshold: 180,
}));
// Simple action handler
onNavItemClick(item: NavItem): void {
switch (item.type) {
case 'href':
window.open(item.href, '_blank');
break;
case 'action':
item.action?.();
break;
default:
// Routes and groups handled elsewhere
break;
}
}
// Private helpers
private _buildWorkContextItems(): NavItem[] {
const items: NavItem[] = [];
const mainContext = this._mainWorkContext();
const inboxContext = this._inboxContext();
if (mainContext) {
items.push({
type: 'workContext',
id: `main-${mainContext.id}`,
label: mainContext.title,
icon: mainContext.icon || 'today',
route: `/tag/${mainContext.id}/tasks`,
workContext: mainContext,
workContextType: WorkContextType.TAG,
defaultIcon: 'today',
});
}
if (inboxContext) {
items.push({
type: 'workContext',
id: `inbox-${inboxContext.id}`,
label: inboxContext.title,
icon: inboxContext.icon || 'inbox',
route: `/project/${inboxContext.id}/tasks`,
workContext: inboxContext,
workContextType: WorkContextType.PROJECT,
defaultIcon: 'inbox',
});
}
return items;
}
private _buildProjectItems(): NavItem[] {
const projects = this._visibleProjects();
const activeId = this._activeWorkContextId();
let filteredProjects = projects;
if (!this._isProjectsExpanded() && activeId) {
// Show only active project when group is collapsed
filteredProjects = projects.filter((project) => project.id === activeId);
}
return filteredProjects.map((project) => ({
type: 'workContext',
id: `project-${project.id}`,
label: project.title,
icon: project.icon || 'folder_special',
route: `/project/${project.id}/tasks`,
workContext: project,
workContextType: WorkContextType.PROJECT,
defaultIcon: project.icon || 'folder_special',
}));
}
private _buildTagItems(): NavItem[] {
const tags = this._tags();
const activeId = this._activeWorkContextId();
let filteredTags = tags;
if (!this._isTagsExpanded() && activeId) {
// Show only active tag when group is collapsed
filteredTags = tags.filter((tag) => tag.id === activeId);
}
return filteredTags.map((tag) => ({
type: 'workContext',
id: `tag-${tag.id}`,
label: tag.title,
icon: tag.icon || 'label',
route: `/tag/${tag.id}/tasks`,
workContext: tag,
workContextType: WorkContextType.TAG,
defaultIcon: tag.icon || 'label',
}));
}
// Public computed signals for expansion state (for component to check)
readonly isProjectsExpanded = computed(() => this._isProjectsExpanded());
readonly isTagsExpanded = computed(() => this._isTagsExpanded());
// Public access to projects for visibility menu
readonly allProjectsExceptInbox = computed(() => this._allProjectsExceptInbox());
// Simple toggle functions
private _toggleProjectsExpanded(): void {
const newState = !this._isProjectsExpanded();
this._isProjectsExpanded.set(newState);
localStorage.setItem(LS.IS_PROJECT_LIST_EXPANDED, newState.toString());
}
private _toggleTagsExpanded(): void {
const newState = !this._isTagsExpanded();
this._isTagsExpanded.set(newState);
localStorage.setItem(LS.IS_TAG_LIST_EXPANDED, newState.toString());
}
// Simple action handlers
private _openCreateProject(): void {
this._matDialog.open(DialogCreateProjectComponent, { restoreFocus: true });
}
private _openBugReport(): void {
window.open(getGithubErrorUrl('', undefined, true), '_blank');
}
private _startTour(tourId: TourId): void {
void this._shepherdService.show(tourId);
}
private _openProjectVisibilityMenu(): void {
// This will trigger the nav-mat-menu component to show the visibility options
// The actual menu items are built by the component based on all projects
}
toggleProjectVisibility(projectId: string): void {
this._store.dispatch(toggleHideFromMenu({ id: projectId }));
}
// Drag and drop handlers
async handleProjectDrop(
items: NavWorkContextItem[],
event: CdkDragDrop<string, string, NavWorkContextItem>,
): Promise<void> {
if (event.previousIndex === event.currentIndex) return;
// Create a copy of the array and move the item
const reorderedItems = [...items];
moveItemInArray(reorderedItems, event.previousIndex, event.currentIndex);
// Get the new order of IDs
const visibleIds = reorderedItems.map((item) => item.workContext!.id);
// Get hidden project IDs to append at the end
const hiddenIds = await this._store
.select(selectUnarchivedHiddenProjectIds)
.pipe(first())
.toPromise();
// Combine visible and hidden IDs
const newIds = [...visibleIds, ...(hiddenIds || [])];
this._projectService.updateOrder(newIds);
}
handleTagDrop(
items: NavWorkContextItem[],
event: CdkDragDrop<string, string, NavWorkContextItem>,
): void {
if (event.previousIndex === event.currentIndex) return;
// Create a copy of the array and move the item
const reorderedItems = [...items];
moveItemInArray(reorderedItems, event.previousIndex, event.currentIndex);
// Get the new order of IDs
const visibleIds = reorderedItems.map((item) => item.workContext!.id);
// Special today list should always be first, so prepend it
const newIds = [TODAY_TAG.id, ...visibleIds.filter((id) => id !== TODAY_TAG.id)];
this._tagService.updateOrder(newIds);
}
// Helper
private _getStoredState(key: string): boolean {
const stored = localStorage.getItem(key);
return stored === null || stored === 'true';
}
}

View file

@ -0,0 +1,169 @@
<!-- Mobile Menu Open Button (only visible on mobile when closed) -->
@if (isMobile() && !showMobileMenuOverlay()) {
<button
class="mobile-menu-open"
(click)="toggleMobileNav()"
[attr.aria-label]="'Open menu'"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
</button>
}
<!-- Overlay for mobile (backdrop) -->
@if (isMobile() && showMobileMenuOverlay()) {
<div
class="nav-backdrop-mobile"
(click)="toggleMobileNav()"
></div>
}
<!-- Main Navigation Sidebar -->
<nav
class="nav-sidebar"
[class.fullMode]="isFullMode() || isMobile()"
[class.compactMode]="!isFullMode() && !isMobile()"
[class.mobile]="isMobile()"
[class.mobile-visible]="isMobile() && showMobileMenuOverlay()"
[class.animate]="animateWidth()"
[class.theme-dark]="config().theme === 'dark'"
[class.position-right]="config().position === 'right'"
[class.resizing]="isResizing()"
[style.width.px]="sidebarWidth()"
role="navigation"
(keydown)="onNavKeyDown($event)"
>
<!-- Desktop Toggle Button (inside sidebar) -->
@if (!isMobile()) {
<button
class="sidebar-toggle"
[class.visible]="!isMobile()"
(click)="toggleSideNavMode()"
[attr.aria-label]="isFullMode() ? 'Switch to compact mode' : 'Switch to full mode'"
[attr.aria-expanded]="isFullMode()"
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path [attr.d]="isFullMode() ? 'M13 4l-6 6 6 6' : 'M7 4l6 6-6 6'" />
</svg>
</button>
}
<!-- Navigation Items -->
<ul
class="nav-list"
role="list"
>
@for (item of config().items; track item.id) {
@switch (item.type) {
@case ('separator') {
<hr
class="nav-separator"
[style.margin-top]="item.mtAuto && 'auto'"
/>
}
@case ('group') {
<li
class="nav-item has-children"
role="listitem"
>
<nav-list
[item]="item"
[showLabels]="showText()"
[isExpanded]="isGroupExpanded(item)"
[activeWorkContextId]="activeWorkContextId()"
(itemClick)="onItemClick($event)"
(dragDrop)="onDragDrop(item, $event)"
></nav-list>
</li>
}
@case ('menu') {
<nav-mat-menu
[item]="item"
[showLabels]="showText()"
(itemClick)="onItemClick($event)"
></nav-mat-menu>
}
@default {
<li
class="nav-item"
role="listitem"
>
@switch (item.type) {
@case ('workContext') {
<nav-item
[workContext]="item.workContext"
[type]="item.workContextType"
[defaultIcon]="item.defaultIcon"
[activeWorkContextId]="activeWorkContextId() || ''"
[variant]="'nav'"
[showLabels]="showText()"
[showMoreButton]="showText()"
(clicked)="onItemClick(item)"
></nav-item>
}
@case ('route') {
<nav-item
[container]="'route'"
[navRoute]="item.route"
[icon]="item.icon"
[svgIcon]="item.svgIcon"
[label]="item.label"
[showLabels]="showText()"
(clicked)="onItemClick(item)"
></nav-item>
}
@case ('href') {
<nav-item
[container]="'href'"
[navHref]="item.href"
[icon]="item.icon"
[svgIcon]="item.svgIcon"
[label]="item.label"
[showLabels]="showText()"
(clicked)="onItemClick(item)"
></nav-item>
}
@case ('action') {
<nav-item
[container]="'action'"
[icon]="item.icon"
[svgIcon]="item.svgIcon"
[label]="item.label"
[showLabels]="showText()"
(clicked)="onItemClick(item)"
></nav-item>
}
}
</li>
}
}
}
</ul>
</nav>
<!-- Resize Handle (moved outside scrolling container) -->
@if (config().resizable && !isMobile()) {
<div
class="resize-handle"
[class.position-left]="config().position !== 'right'"
[class.position-right]="config().position === 'right'"
[class.compactMode-handle]="!isFullMode()"
(mousedown)="onResizeStart($event)"
[attr.aria-label]="'Resize sidebar'"
role="separator"
></div>
}

View file

@ -0,0 +1,291 @@
:host {
--sidebar-width-expanded: var(--side-nav-width, 260px);
--mobile-breakpoint: 768px;
--sidebar-transition-duration: var(--transition-duration-m, 0.3s);
--sidebar-transition-timing: var(--ani-standard-timing, cubic-bezier(0.4, 0, 0.2, 1));
--mat-menu-item-spacing: 4px;
// Ensure the component itself participates in layout and animates width
display: block;
flex: 0 0 auto;
transition: none;
will-change: width;
position: relative;
z-index: var(--z-side-nav);
}
// Enable host width transition only when toggling compactMode/fullMode
:host(.animate) {
transition: width var(--sidebar-transition-duration) var(--sidebar-transition-timing);
}
// Disable host transition during resize drags
:host(.resizing) {
transition: none !important;
}
// Mobile menu open button (only shows when menu is closed)
.mobile-menu-open {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 8px;
left: 8px;
background: var(--sidenav-bg);
border: 1px solid var(--sidebar-border);
border-radius: 8px;
padding: 8px;
cursor: pointer;
transition: var(--transition-standard);
color: var(--text-color);
&:hover {
background: var(--sidebar-hover);
}
svg {
color: var(--sidebar-text);
}
}
// Dark theme milkglass adjustments
:host-context(.isDarkTheme.hasBgImage) :host {
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px) saturate(180%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
-webkit-backdrop-filter: blur(20px) saturate(180%); // Safari support
}
// Mobile overlay
.nav-backdrop-mobile {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
background: var(--overlay-bg);
z-index: var(--z-backdrop, 1001);
transition: opacity var(--sidebar-transition-duration) var(--sidebar-transition-timing);
display: block;
opacity: 1;
}
// Main sidebar
.nav-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-width-expanded);
background: var(--sidenav-bg);
border-right: 1px solid var(--sidebar-border);
// Default: don't animate width
transition: none;
z-index: 1002;
overflow-y: auto;
overflow-x: hidden;
// Slight elevation and clear background for overlay/mobile mode is defined in the mobile block below
&.animate {
transition: width var(--sidebar-transition-duration) var(--sidebar-transition-timing);
}
&.resizing {
transition: none !important;
}
// When resizing, also suppress label/text transitions inside entries
&.resizing .nav-label,
&.resizing .text {
transition: none !important;
}
// Milkglass effect when background image is present
// This will be applied when a CSS class is added to indicate background image presence
:host-context(.hasBgImage) {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px) saturate(180%);
border-right: 1px solid rgba(255, 255, 255, 0.1);
-webkit-backdrop-filter: blur(20px) saturate(180%); // Safari support
}
// removed unused expand/collapse zone visuals
// Scrollbar styling
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--sidebar-border);
border-radius: 3px;
&:hover {
background: var(--sidebar-text-secondary);
}
}
// CompactMode state (desktop)
&.compactMode {
// width controlled via inline style for smooth transitions
.sidebar-toggle {
right: auto;
left: 50%;
transform: translateX(-50%);
}
}
// Mobile specific
&.mobile {
transition: transform var(--sidebar-transition-duration)
var(--sidebar-transition-timing);
will-change: transform;
transform: translateX(-100%);
width: var(--sidebar-width-expanded);
background: var(--sidenav-bg-mobile);
&.mobile-visible {
box-shadow: 4px 0 16px rgba(0, 0, 0, 0.3);
transform: translateX(0);
}
}
// Position right
&.position-right {
left: auto;
right: 0;
border-right: none;
border-left: 1px solid var(--sidebar-border);
&.mobile {
transform: translateX(100%);
&.mobile-visible {
transform: translateX(0);
}
}
.sidebar-toggle {
left: 16px;
right: auto;
}
// removed unused collapse/expand zone visuals for right-position
}
// Note: Theme colors are handled by CSS variables above
}
// Sidebar toggle button (desktop)
.sidebar-toggle {
display: none;
position: absolute;
top: 16px;
right: 16px;
background: transparent;
border: 1px solid var(--sidebar-border);
border-radius: 6px;
padding: 6px;
cursor: pointer;
transition: var(--transition-standard);
color: var(--sidebar-text-secondary);
&.visible {
display: flex;
align-items: center;
justify-content: center;
}
&:hover {
background: var(--sidebar-hover);
color: var(--sidebar-text);
}
}
// Navigation list
.nav-list {
list-style: none;
//padding: 60px var(--s) var(--s2);
padding: 0;
padding-top: 60px;
margin: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
// Remove top padding on mobile (600px breakpoint)
@media (max-width: 600px) {
padding-top: 0;
}
}
.nav-item {
margin-bottom: 2px;
&.has-children {
margin-bottom: 4px;
}
}
// Separator styling
.nav-separator {
height: 1px;
background: var(--divider-color);
margin: var(--s2) var(--s2);
border: none;
opacity: 0.7;
}
// Resize handle (now positioned relative to host component)
.resize-handle {
position: absolute;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
z-index: 1003; // Above the nav-sidebar
transition: background-color 0.2s ease;
&.position-left {
right: -2px;
}
&.position-right {
left: -2px;
}
// CompactMode specific styling
&.compactMode-handle {
width: 8px;
&.position-left {
right: -4px;
}
&.position-right {
left: -4px;
}
}
&:hover {
background-color: var(--sidenav-active-text);
}
&:active {
background-color: var(--sidenav-active-text);
}
}
// Multi-button wrapper styling moved to nav-list component
// Media-query rules affecting link rows moved to nav-item component

View file

@ -0,0 +1,476 @@
/* eslint-disable @typescript-eslint/naming-convention */
import {
Component,
computed,
effect,
HostListener,
inject,
input,
OnDestroy,
OnInit,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { NavItemComponent } from './nav-item/nav-item.component';
import { NavSectionComponent } from './nav-list/nav-list.component';
import { NavGroupItem, NavItem, NavWorkContextItem } from './magic-side-nav.model';
import { LS } from '../../core/persistence/storage-keys.const';
import { MagicNavConfigService } from './magic-nav-config.service';
import { readBoolLS, readNumberLSBounded } from '../../util/ls-util';
import { MatMenuModule } from '@angular/material/menu';
import { NavMatMenuComponent } from './nav-mat-menu/nav-mat-menu.component';
import { TaskService } from '../../features/tasks/task.service';
const COLLAPSED_WIDTH = 64;
@Component({
selector: 'magic-side-nav',
standalone: true,
imports: [
CommonModule,
RouterModule,
NavItemComponent,
NavSectionComponent,
MatMenuModule,
NavMatMenuComponent,
],
templateUrl: './magic-side-nav.component.html',
styleUrl: './magic-side-nav.component.scss',
host: {
'[style.width.px]': 'hostWidthSignal()',
'[class.animate]': 'animateWidth()',
'[class.resizing]': 'isResizing()',
},
})
export class MagicSideNavComponent implements OnInit, OnDestroy {
private readonly _sideNavConfigService = inject(MagicNavConfigService);
private readonly _taskService = inject(TaskService);
// Use service's computed signal directly
readonly config = this._sideNavConfigService.navConfig;
activeWorkContextId = input<string | null>(null);
// Externally controlled mobile overlay visibility
mobileVisibleChange = output<boolean>();
isFullMode = signal(true);
isMobile = signal(false);
showMobileMenuOverlay = signal(false);
// Track expanded groups as array for better signal change detection
expandedGroups = signal<string[]>([]);
// Merge service-controlled and local expanded ids for reactive checks
// Animate only for compactMode/fullMode toggle
animateWidth = signal(false);
private _animateTimeoutId: number | null = null;
// Resize functionality
currentWidth = signal(260);
// Use values directly from config for min/max/thresholds
isResizing = signal(false);
startX = signal(0);
startWidth = signal(0);
// Computed values
sidebarWidth = computed(() => {
if (this.isMobile()) return 260;
if (!this.isFullMode()) return COLLAPSED_WIDTH;
return this.currentWidth();
});
// Host width as computed signal: don't reserve space on mobile overlay
readonly hostWidthSignal = computed(() => (this.isMobile() ? 0 : this.sidebarWidth()));
// Commonly used derived state for template readability
readonly showText = computed(() => this.isFullMode() || this.isMobile());
// Keep stable references for event listeners to ensure proper cleanup
private readonly _onDrag: (event: MouseEvent) => void = (event: MouseEvent) =>
this._handleDrag(event);
private readonly _onDragEnd: () => void = () => this._handleDragEnd();
constructor() {
// Emit mobile visible changes to parent while in mobile mode
effect(() => {
if (this.isMobile()) {
this.mobileVisibleChange.emit(this.showMobileMenuOverlay());
}
});
}
ngOnDestroy(): void {
if (this._animateTimeoutId != null) {
window.clearTimeout(this._animateTimeoutId);
this._animateTimeoutId = null;
}
}
ngOnInit(): void {
// Load saved fullMode/compactMode state or default to config value
const initialFullMode = readBoolLS(
LS.NAV_SIDEBAR_EXPANDED,
this.config().fullModeByDefault,
);
this.isFullMode.set(initialFullMode);
// Load saved width from localStorage or default
const bounded = readNumberLSBounded(
LS.NAV_SIDEBAR_WIDTH,
this.config().minWidth,
this.config().maxWidth,
);
this.currentWidth.set(bounded ?? this.config().defaultWidth);
// Initialize expanded groups for non-service-managed groups
// Projects and tags expansion is managed by the service
const nextExpanded: string[] = [];
this.expandedGroups.set(nextExpanded);
this._checkScreenSize();
}
@HostListener('window:resize')
onWindowResize(): void {
this._checkScreenSize();
}
onNavKeyDown(event: KeyboardEvent): void {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
this._handleArrowNavigation(event);
} else if (event.key === 'Escape') {
this._handleEscapeKey(event);
}
}
private _checkScreenSize(): void {
const wasMobile = this.isMobile();
const currentMobile = window.innerWidth < (this.config().mobileBreakpoint || 768);
this.isMobile.set(currentMobile);
if (wasMobile !== currentMobile) {
if (currentMobile) {
this.showMobileMenuOverlay.set(false);
} else {
this.isFullMode.set(this.config().fullModeByDefault);
}
}
}
toggleMobileNav(): void {
this.showMobileMenuOverlay.update((show) => !show);
console.log(this.showMobileMenuOverlay());
}
toggleSideNavMode(): void {
this._enableWidthAnimation();
const newFullMode = !this.isFullMode();
this.isFullMode.set(newFullMode);
// Save fullMode/compactMode state to localStorage
localStorage.setItem(LS.NAV_SIDEBAR_EXPANDED, newFullMode.toString());
// handled internally
}
toggleGroup(item: NavItem): void {
if (item.type !== 'group' || !item.children) {
return;
}
const groups = this.expandedGroups();
if (groups.includes(item.id)) {
this.expandedGroups.set(groups.filter((g) => g !== item.id));
} else {
this.expandedGroups.set([...groups, item.id]);
}
}
isGroupExpanded(item: NavItem): boolean {
// Use the service as the source of truth for expansion state
if (item.id === 'projects') {
return this._sideNavConfigService.isProjectsExpanded();
} else if (item.id === 'tags') {
return this._sideNavConfigService.isTagsExpanded();
}
// For other groups, fall back to local state
return this.expandedGroups().includes(item.id);
}
onItemClick(item: NavItem): void {
if (item.type === 'group') {
// For projects and tags, let the service handle the toggle to avoid double-toggle
if (item.id === 'projects' || item.id === 'tags') {
if (item.action) {
item.action(); // This will update the service state
}
return;
}
// For other groups, handle locally
if (item.action) {
item.action();
}
this.toggleGroup(item);
return;
}
if (item.type === 'action') {
item.action?.();
}
// Handle via service for actions/hrefs
this._sideNavConfigService.onNavItemClick(item);
if (this.isMobile()) {
this.showMobileMenuOverlay.set(false);
}
}
onDragDrop(
groupItem: NavGroupItem,
dropData: {
items: NavWorkContextItem[];
event: CdkDragDrop<string, string, NavWorkContextItem>;
},
): void {
const { items, event } = dropData;
if (groupItem.id === 'projects') {
this._sideNavConfigService.handleProjectDrop(items, event);
} else if (groupItem.id === 'tags') {
this._sideNavConfigService.handleTagDrop(items, event);
}
}
// Resize functionality
onResizeStart(event: MouseEvent): void {
if (!this.config().resizable || this.isMobile()) return;
this.isResizing.set(true);
this.startX.set(event.clientX);
this.startWidth.set(this.isFullMode() ? this.currentWidth() : COLLAPSED_WIDTH);
document.addEventListener('mousemove', this._onDrag);
document.addEventListener('mouseup', this._onDragEnd);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
event.preventDefault();
}
private _handleDrag(event: MouseEvent): void {
if (!this.isResizing()) return;
const deltaX =
this.config().position === 'right'
? this.startX() - event.clientX
: event.clientX - this.startX();
const potentialWidth = this.startWidth() + deltaX;
const cfg = this.config();
const collapseTh = cfg.collapseThreshold;
const expandTh = cfg.expandThreshold;
const minW = cfg.minWidth;
const maxW = cfg.maxWidth;
// Handle seamless mode transitions
if (this.isFullMode()) {
// Currently fullMode - check for compactMode threshold
if (potentialWidth < collapseTh) {
// Auto-switch to compactMode without releasing mouse
this.isFullMode.set(false);
// handled internally
// Reset starting point for continued dragging from compactMode state
this.startWidth.set(COLLAPSED_WIDTH);
this.startX.set(event.clientX);
// No visual feedback
return;
}
// No visual feedback when approaching collapse threshold
// Normal resize when fullMode
const newWidth = Math.max(minW, Math.min(maxW, potentialWidth));
this.currentWidth.set(newWidth);
} else {
// Currently compactMode - check for fullMode threshold
const compactModeWidth = COLLAPSED_WIDTH;
const draggedWidth = compactModeWidth + deltaX;
if (draggedWidth > expandTh) {
// Auto-switch to fullMode without releasing mouse
this.isFullMode.set(true);
const newWidth = expandTh + 20; // Start slightly beyond threshold
this.currentWidth.set(newWidth);
// handled internally
// Reset starting point for continued dragging from fullMode state
this.startWidth.set(this.currentWidth());
this.startX.set(event.clientX);
// No visual feedback
return;
}
// Show visual feedback when approaching threshold
// No visual feedback when approaching expand threshold
}
}
private _handleDragEnd(): void {
if (!this.isResizing()) return;
this.isResizing.set(false);
document.removeEventListener('mousemove', this._onDrag);
document.removeEventListener('mouseup', this._onDragEnd);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// No visual feedback to reset
// Save width to localStorage (only save if fullMode)
if (this.isFullMode()) {
localStorage.setItem(LS.NAV_SIDEBAR_WIDTH, this.currentWidth().toString());
}
}
private _enableWidthAnimation(): void {
if (this._animateTimeoutId != null) {
window.clearTimeout(this._animateTimeoutId);
this._animateTimeoutId = null;
}
this.animateWidth.set(true);
// Slightly longer than --transition-duration-m (225ms) to ensure cleanup
this._animateTimeoutId = window.setTimeout(() => {
this.animateWidth.set(false);
this._animateTimeoutId = null;
}, 300);
}
private _handleArrowNavigation(event: KeyboardEvent): void {
// Get all focusable elements in the nav
const focusableElements = this._getFocusableNavElements();
if (focusableElements.length === 0) {
return;
}
const activeElement = document.activeElement as HTMLElement;
const currentIndex = focusableElements.indexOf(activeElement);
event.preventDefault();
event.stopPropagation();
let nextIndex: number;
if (currentIndex === -1) {
// No nav element focused, focus first element
nextIndex = 0;
} else if (event.key === 'ArrowDown') {
nextIndex = (currentIndex + 1) % focusableElements.length;
} else if (event.key === 'ArrowUp') {
nextIndex = currentIndex === 0 ? focusableElements.length - 1 : currentIndex - 1;
} else {
return;
}
focusableElements[nextIndex]?.focus();
}
private _getFocusableNavElements(): HTMLElement[] {
const nav = document.querySelector('.nav-sidebar');
if (!nav) {
return [];
}
// Get the sidebar toggle button first (if visible)
const toggleButton = nav.querySelector('.sidebar-toggle') as HTMLElement;
const focusableElements: HTMLElement[] = [];
if (toggleButton) {
const styles = window.getComputedStyle(toggleButton);
if (styles.display !== 'none' && styles.visibility !== 'hidden') {
toggleButton.setAttribute('tabindex', '0');
focusableElements.push(toggleButton);
}
}
// Get all nav-link elements (these are the actual clickable items)
// Also get any mat-menu-item elements which are used for nav items
const selector = '.nav-link, [mat-menu-item]';
const allElements = Array.from(nav.querySelectorAll(selector)) as HTMLElement[];
// Filter to only get elements within the nav-list
const navList = nav.querySelector('.nav-list');
if (!navList) {
return focusableElements; // Return just the toggle if no nav list
}
const navListElements = allElements.filter((el) => {
// Make sure the element is inside nav-list
const isInNavList = navList.contains(el);
// Check if it's actually interactive (button or link)
const tagName = el.tagName.toLowerCase();
const isInteractive = tagName === 'button' || tagName === 'a';
// Check visibility
const styles = window.getComputedStyle(el);
const isVisible =
styles.display !== 'none' &&
styles.visibility !== 'hidden' &&
styles.opacity !== '0';
// Exclude settings buttons and other non-navigation buttons
const isSettingsBtn = el.classList.contains('additional-btn');
return isInNavList && isInteractive && isVisible && !isSettingsBtn;
});
// Set tabindex on nav list elements to make them focusable
navListElements.forEach((el) => {
if (!el.hasAttribute('tabindex') || el.getAttribute('tabindex') === '-1') {
el.setAttribute('tabindex', '0');
}
});
// Combine toggle button (if present) with nav list elements
const allFocusable = [...focusableElements, ...navListElements];
return allFocusable;
}
private _handleEscapeKey(event: KeyboardEvent): void {
// Unfocus any currently focused navigation element
const activeElement = document.activeElement as HTMLElement;
const navSidebar = document.querySelector('.nav-sidebar');
// Check if the currently focused element is within the navigation
if (navSidebar && navSidebar.contains(activeElement)) {
event.preventDefault();
event.stopPropagation();
// Unfocus the navigation element
if (activeElement && typeof activeElement.blur === 'function') {
activeElement.blur();
}
// Focus the first task if available
setTimeout(() => {
this._taskService.focusFirstTaskIfVisible();
}, 10);
}
}
// Public method to focus the first nav entry (for keyboard shortcuts)
focusFirstNavEntry(): void {
const focusableElements = this._getFocusableNavElements();
if (focusableElements.length > 0) {
focusableElements[0].focus();
}
}
// Inline width binding via template replaces imperative DOM style updates
}

View file

@ -0,0 +1,104 @@
import {
WorkContextCommon,
WorkContextType,
} from '../../features/work-context/work-context.model';
export type NavItem =
| NavSeparatorItem
| NavWorkContextItem
| NavRouteItem
| NavHrefItem
| NavActionItem
| NavGroupItem
| NavMenuItem;
export interface NavBaseItem {
id: string;
label?: string;
icon?: string;
svgIcon?: string;
}
export interface NavSeparatorItem extends NavBaseItem {
type: 'separator';
mtAuto?: true;
}
export interface NavWorkContextItem extends NavBaseItem {
type: 'workContext';
label: string;
icon: string;
route: string;
workContext: WorkContextCommon;
workContextType: WorkContextType;
defaultIcon: string;
}
export interface NavRouteItem extends NavBaseItem {
type: 'route';
label: string;
icon: string;
route: string;
}
export interface NavHrefItem extends NavBaseItem {
type: 'href';
label: string;
icon: string;
href: string;
}
export interface NavActionItem extends NavBaseItem {
type: 'action';
label: string;
icon: string;
action: () => void;
}
export interface NavContextItem {
id: string;
label: string;
icon: string;
svgIcon?: string;
action?: () => void;
}
export interface NavGroupItem extends NavBaseItem {
type: 'group';
label: string;
icon: string;
children: NavItem[];
additionalButtons?: NavAdditionalButton[];
contextMenuItems?: NavContextItem[];
action?: () => void; // optional external toggle logic
}
export interface NavMenuItem extends NavBaseItem {
type: 'menu';
label: string;
icon: string;
children: NavItem[];
}
export interface NavAdditionalButton {
id: string;
icon: string;
tooltip?: string;
action?: () => void;
hidden?: boolean;
contextMenu?: NavContextItem[];
}
export interface NavConfig {
items: NavItem[];
fullModeByDefault: boolean;
showLabels: boolean;
mobileBreakpoint: number;
position: 'left' | 'right';
theme: 'light' | 'dark';
resizable: boolean;
minWidth: number;
maxWidth: number;
defaultWidth: number;
collapseThreshold: number;
expandThreshold: number;
}

View file

@ -0,0 +1,189 @@
@if (mode() === 'work' && workContext()) {
<div
class="planner-drag-place-holder-shared"
*cdkDragPlaceholder
></div>
<button
#routeBtn
[routerLink]="[type() === 'TAG' ? 'tag' : 'project', workContext()!.id, 'tasks']"
routerLinkActive="active"
class="nav-link"
[class.has-tasks]="nrOfOpenTasks() > 0"
mat-menu-item
(click)="clicked.emit()"
>
<mat-icon
class="nav-icon"
[class.drag-handle]="defaultIcon() !== 'today' && defaultIcon() !== 'inbox'"
>{{ workContext()!.icon || defaultIcon() }}</mat-icon
>
<span
class="nav-label"
[class.label-hidden]="!showLabels()"
[attr.aria-hidden]="!showLabels() ? 'true' : 'false'"
>
{{ workContext()!.title }}
</span>
</button>
<div class="task-count">{{ nrOfOpenTasks() }}</div>
<button
[style.visibility]="showMoreButton() ? 'visible' : 'hidden'"
#settingsBtn
class="additional-btn"
mat-icon-button
>
<mat-icon>more_vert</mat-icon>
</button>
<context-menu
[contextMenu]="contextMenu"
[rightClickTriggerEl]="routeBtn"
[leftClickTriggerEl]="settingsBtn"
></context-menu>
<ng-template #contextMenu>
<work-context-menu
[contextId]="workContext()!.id"
[contextType]="type()!"
></work-context-menu>
</ng-template>
} @else {
<!-- Container + presentational row unified here -->
@if (container() === 'route') {
<a
[routerLink]="navRoute()"
routerLinkActive="active"
mat-menu-item
class="nav-link"
[class.expanded]="expanded()"
(click)="clicked.emit()"
>
@if (svgIcon()) {
<mat-icon
class="nav-icon"
[class.expand-icon]="container() === 'group' && svgIcon() === 'expand_more'"
[class.icon--svg]="true"
[class.icon--early-on]="svgIcon() === 'early_on'"
[svgIcon]="svgIcon()"
></mat-icon>
} @else if (icon()) {
<mat-icon
class="nav-icon"
[class.expand-icon]="container() === 'group' && icon() === 'expand_more'"
>
{{ icon() }}
</mat-icon>
}
@if (label()) {
<span
class="nav-label"
[class.label-hidden]="!showLabels()"
[attr.aria-hidden]="!showLabels() ? 'true' : 'false'"
>
{{ label() | translate }}
</span>
}
</a>
} @else if (container() === 'href') {
<a
[href]="navHref()"
target="_blank"
mat-menu-item
rel="noopener noreferrer"
class="nav-link"
[class.expanded]="expanded()"
(click)="clicked.emit()"
>
@if (svgIcon()) {
<mat-icon
class="nav-icon"
[class.expand-icon]="container() === 'group' && svgIcon() === 'expand_more'"
[class.icon--svg]="true"
[class.icon--early-on]="svgIcon() === 'early_on'"
[svgIcon]="svgIcon()"
></mat-icon>
} @else if (icon()) {
<mat-icon
class="nav-icon"
[class.expand-icon]="container() === 'group' && icon() === 'expand_more'"
>
{{ icon() }}
</mat-icon>
}
@if (label()) {
<span
class="nav-label"
[class.label-hidden]="!showLabels()"
[attr.aria-hidden]="!showLabels() ? 'true' : 'false'"
>
{{ label() | translate }}
</span>
}
</a>
} @else if (container() === 'action' || container() === 'group') {
<button
class="nav-link"
mat-menu-item
[class.expanded]="container() === 'group' && expanded()"
[attr.aria-expanded]="container() === 'group' ? expanded() : null"
[attr.aria-controls]="container() === 'group' ? ariaControls() : null"
[matMenuTriggerFor]="menuTriggerFor()"
(click)="clicked.emit()"
>
@if (svgIcon()) {
<mat-icon
class="nav-icon"
[class.expand-icon]="container() === 'group' && svgIcon() === 'expand_more'"
[class.icon--svg]="true"
[class.icon--early-on]="svgIcon() === 'early_on'"
[svgIcon]="svgIcon()"
></mat-icon>
} @else if (icon()) {
<mat-icon
class="nav-icon"
[class.expand-icon]="container() === 'group' && icon() === 'expand_more'"
>
{{ icon() }}
</mat-icon>
}
@if (label()) {
<span
class="nav-label"
[class.label-hidden]="!showLabels()"
[attr.aria-hidden]="!showLabels() ? 'true' : 'false'"
>
{{ label() | translate }}
</span>
}
</button>
} @else {
<!-- Fallback: raw row (kept for compatibility) -->
@if (svgIcon()) {
<mat-icon
class="nav-icon"
[class.expand-icon]="container() === 'group' && svgIcon() === 'expand_more'"
[svgIcon]="svgIcon()"
></mat-icon>
} @else if (icon()) {
<mat-icon
class="nav-icon"
[class.expand-icon]="container() === 'group' && icon() === 'expand_more'"
>
{{ icon() }}
</mat-icon>
}
@if (label()) {
<span
class="nav-label"
[class.label-hidden]="!showLabels()"
[attr.aria-hidden]="!showLabels() ? 'true' : 'false'"
>
{{ label() | translate }}
</span>
}
}
}

View file

@ -0,0 +1,281 @@
@use 'styles/globals' as *;
:host {
display: flex !important;
align-items: stretch;
position: relative;
width: 100%;
color: var(--text-color);
button:first-of-type {
white-space: nowrap;
overflow: hidden;
flex: 1;
min-width: 0;
// to make space for the task-count
&.has-tasks {
padding-right: 40px;
}
.nav-label {
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
// smooth collapse/expand for title
display: inline-block;
white-space: nowrap;
max-width: 100%;
transition:
opacity var(--transition-duration-m, 0.25s) var(--ani-standard-timing),
max-width var(--transition-duration-m, 0.25s) var(--ani-standard-timing),
margin var(--transition-duration-m, 0.25s) var(--ani-standard-timing);
}
.ico-wrapper {
position: relative;
}
}
.additional-btn {
flex-shrink: 0;
width: 40px !important;
min-width: 40px !important;
}
&.isHidden {
display: none !important;
}
}
:host.isActiveContext button {
font-weight: normal;
}
:host:hover .task-count {
display: none !important;
}
@media (hover: none) {
.additional-btn {
opacity: 1;
visibility: visible;
}
.task-count {
display: none !important;
}
}
.task-count {
position: absolute;
top: 50%;
transform: translateY(-50%);
line-height: 1;
text-align: center;
font-size: 10px;
right: 16px;
display: flex;
align-items: center;
// avoid affecting drag handle
pointer-events: none;
@include mq(xs, max) {
display: none;
}
}
// Active state visuals are handled by .nav-link.active styles
// Styling when used inside magic-side-nav: rely on global .nav-link/.nav-child-link
// Only keep adjustments for the additional button here
:host.variant-nav {
.g-multi-btn-wrapper {
background-color: transparent;
}
.additional-btn {
color: var(--sidebar-text-secondary);
&:hover {
background-color: var(--sidebar-hover);
color: var(--sidebar-text);
}
}
}
// Smooth label show/hide for presentational rows
.nav-label {
display: inline-block;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
transition:
opacity var(--transition-duration-m, 0.25s) var(--ani-standard-timing),
max-width var(--transition-duration-m, 0.25s) var(--ani-standard-timing),
margin var(--transition-duration-m, 0.25s) var(--ani-standard-timing);
}
.nav-label.label-hidden,
.text.label-hidden {
opacity: 0;
max-width: 0;
margin-left: 0 !important;
pointer-events: none;
}
.drag-handle {
/* Firefox 1.5-26 */
position: relative;
@include grabCursor();
&:after {
content: '';
position: absolute;
top: calc(-1 * var(--s));
left: calc(-1 * var(--s));
right: calc(-1 * var(--s));
bottom: calc(-1 * var(--s));
}
}
// Standard navigation row styles (simplified to be the only style)
.nav-link {
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s) var(--s2);
color: var(--sidebar-text-secondary);
text-decoration: none;
border-radius: 6px;
transition: all 0.2s ease;
cursor: pointer;
background: transparent;
border: none;
width: 100%;
text-align: left;
font-size: 14px;
&:hover {
background: var(--sidebar-hover);
color: var(--sidebar-text);
}
&.active {
background: var(--sidenav-active);
color: var(--sidenav-active-text);
.nav-icon {
color: var(--sidenav-active-text);
}
}
.nav-icon {
width: 20px;
height: 20px;
font-size: 16px;
}
}
// Navigation icon and chevron
.nav-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--sidebar-text-secondary);
font-size: 18px;
line-height: 1;
}
// Removed legacy chevron styles; we rotate expand_more icon instead
// Rotate the expand_more icon for group headers instead of showing a separate chevron
.expand-icon {
transition: transform 0.2s ease;
}
.expanded .expand-icon {
transform: rotate(180deg);
}
// Collapsed state adjustments from parent sidebar
:host-context(.isLightTheme .nav-sidebar.compactMode) {
.nav-link {
&.active {
color: var(--sidenav-active-text);
background: var(--sidenav-active);
border-radius: 48px;
border-bottom: 1px solid var(--separator-color);
//border: 1px solid var(--separator-color);
border-top: 1px solid var(--separator-color);
&::after {
position: absolute;
inset: 4px;
}
.nav-icon {
color: var(--sidenav-active-text);
}
}
}
}
:host-context(.nav-sidebar.compactMode) {
.nav-link {
justify-content: center;
padding: 12px;
// Keep dedicated compactMode hover/active feel
&:hover {
background: var(--sidebar-hover);
}
&.active {
color: var(--sidenav-active-text);
background: var(--sidenav-active);
&::after {
position: absolute;
inset: 4px;
}
.nav-icon {
color: var(--sidenav-active-text);
}
}
}
.nav-icon {
margin: 0;
}
// Hide the overflow menu button entirely when compactMode
.additional-btn {
display: none !important;
}
// Hide Material's menu item text wrapper (keeps icon centered, avoids jump)
a:first-of-type ::ng-deep .mat-mdc-menu-item-text,
button:first-of-type ::ng-deep .mat-mdc-menu-item-text {
display: none !important;
}
.task-count {
display: none;
}
// (no duplicate .nav-link styles here; use global ones)
}
@include mq(xs, max) {
:host-context(.nav-sidebar.compactMode) {
.nav-link {
justify-content: flex-start;
padding: 10px 12px;
}
}
}

View file

@ -0,0 +1,126 @@
import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { RouterLink, RouterModule } from '@angular/router';
import {
WorkContextCommon,
WorkContextType,
} from '../../../features/work-context/work-context.model';
import { Project } from '../../../features/project/project.model';
import { WorkContextMenuComponent } from '../../work-context-menu/work-context-menu.component';
import { ContextMenuComponent } from '../../../ui/context-menu/context-menu.component';
import { CdkDragPlaceholder } from '@angular/cdk/drag-drop';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuItem, MatMenuModule } from '@angular/material/menu';
import { toSignal } from '@angular/core/rxjs-interop';
import { selectAllDoneIds } from '../../../features/tasks/store/task.selectors';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'nav-item',
imports: [
RouterLink,
RouterModule,
WorkContextMenuComponent,
ContextMenuComponent,
CdkDragPlaceholder,
MatIconButton,
MatIcon,
MatMenuItem,
MatMenuModule,
TranslatePipe,
],
templateUrl: './nav-item.component.html',
styleUrl: './nav-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'g-multi-btn-wrapper',
// eslint-disable-next-line @typescript-eslint/naming-convention
'[class.hasTasks]': 'workContextHasTasks()',
// eslint-disable-next-line @typescript-eslint/naming-convention
'[class.isActiveContext]': 'isActiveContext()',
// eslint-disable-next-line @typescript-eslint/naming-convention
'[class.isHidden]': 'isHidden()',
// eslint-disable-next-line @typescript-eslint/naming-convention
'[class.variant-nav]': "variant() === 'nav'",
},
standalone: true,
})
export class NavItemComponent {
private readonly _store = inject(Store);
// Mode selection
mode = input<'work' | 'row'>('work');
// Container selection for non-work modes
container = input<'route' | 'href' | 'action' | 'group' | null>(null);
navRoute = input<string | any[] | undefined>(undefined);
navHref = input<string | undefined>(undefined);
expanded = input<boolean>(false);
ariaControls = input<string | null>(null);
// Work context inputs
workContext = input<WorkContextCommon | null>(null);
type = input<WorkContextType | null>(null);
defaultIcon = input<string>('folder_special');
activeWorkContextId = input<string>('');
// Variant styling to integrate into magic-side-nav without deep selectors
variant = input<'default' | 'nav'>('default');
showMoreButton = input<boolean>(true);
// Presentational row inputs
label = input<string | undefined>(undefined);
icon = input<string | undefined>(undefined);
svgIcon = input<string | undefined>(undefined);
showLabels = input<boolean>(true);
// Optional: menu trigger for dropdown
menuTriggerFor = input<any | null>(null);
// Events
clicked = output<void>();
allUndoneTaskIds = toSignal(this._store.select(selectAllDoneIds), { initialValue: [] });
nrOfOpenTasks = computed<number>(() => {
const wc = this.workContext();
if (!wc) return 0;
const allUndoneTaskIds = this.allUndoneTaskIds();
return wc.taskIds.filter((tid) => !allUndoneTaskIds.includes(tid)).length;
});
private readonly _routeBtn = viewChild('routeBtn', { read: ElementRef });
workContextHasTasks = computed<boolean>(() => {
const wc = this.workContext();
return !!wc && wc.taskIds.length > 0;
});
isActiveContext = computed<boolean>(() => {
const wc = this.workContext();
return !!wc && wc.id === this.activeWorkContextId();
});
isHidden = computed<boolean>(() => {
const wc = this.workContext();
return !!(wc as Project | null)?.isHiddenFromMenu;
});
// removed redundant computed flags: use inputs directly in host bindings
focus(): void {
const btn = this._routeBtn();
if (btn) {
btn.nativeElement.focus();
}
}
}

View file

@ -0,0 +1,140 @@
<div class="g-multi-btn-wrapper">
<nav-item
[container]="'group'"
[expanded]="isExpanded()"
[ariaControls]="'group-' + item().id"
[icon]="item().icon"
[svgIcon]="item().svgIcon"
[label]="item().label"
[showLabels]="showLabels()"
(clicked)="onHeaderClick()"
></nav-item>
@if (showLabels()) {
<div class="additional-btns">
@for (btn of item().additionalButtons; track btn.id) {
@if (!btn.hidden) {
@if (btn.id === 'project-visibility') {
<button
#visibilityBtn
class="additional-btn"
mat-icon-button
[matTooltip]="btn.tooltip | translate"
[matMenuTriggerFor]="visibilityMenu"
>
<mat-icon>{{ btn.icon }}</mat-icon>
</button>
} @else {
<button
class="additional-btn"
mat-icon-button
[matTooltip]="btn.tooltip | translate"
(click)="btn.action?.()"
>
<mat-icon>{{ btn.icon }}</mat-icon>
</button>
}
}
}
</div>
}
</div>
@if (item().children && item().children.length > 0 && isExpanded()) {
<ul
[@expandFade]
[@standardList]="item().children.length"
class="nav-children"
[attr.id]="'group-' + item().id"
role="list"
cdkDropList
[cdkDropListSortingDisabled]="false"
[cdkDropListAutoScrollDisabled]="true"
(cdkDropListDropped)="onDrop($event)"
>
@for (child of item().children; track child.id) {
@if (child.type === 'workContext') {
<li
class="nav-child-item"
[class.draggable]="
child.defaultIcon !== 'today' && child.defaultIcon !== 'inbox'
"
role="listitem"
cdkDrag
[cdkDragData]="child"
[cdkDragStartDelay]="IS_TOUCH_PRIMARY ? DRAG_DELAY_FOR_TOUCH_LONGER : 0"
>
<nav-item
[workContext]="child.workContext"
[type]="child.workContextType"
[defaultIcon]="child.defaultIcon"
[activeWorkContextId]="activeWorkContextId() || ''"
[variant]="'nav'"
[showLabels]="true"
[showMoreButton]="true"
(clicked)="onChildClick(child)"
></nav-item>
</li>
} @else {
<li
class="nav-child-item"
role="listitem"
>
@switch (child.type) {
@case ('route') {
<nav-item
[container]="'route'"
[navRoute]="child.route"
[icon]="child.icon"
[svgIcon]="child.svgIcon"
[label]="child.label"
[showLabels]="true"
(clicked)="onChildClick(child)"
></nav-item>
}
@case ('href') {
<nav-item
[container]="'href'"
[navHref]="child.href"
[icon]="child.icon"
[svgIcon]="child.svgIcon"
[label]="child.label"
[showLabels]="true"
(clicked)="onChildClick(child)"
></nav-item>
}
@case ('action') {
<nav-item
[container]="'action'"
[icon]="child.icon"
[svgIcon]="child.svgIcon"
[label]="child.label"
[showLabels]="true"
(clicked)="onChildClick(child)"
></nav-item>
}
}
</li>
}
}
</ul>
}
<!-- Project Visibility Menu -->
<!--@if (item().id === 'projects') {-->
<mat-menu #visibilityMenu="matMenu">
@for (project of allProjectsExceptInbox(); track project.id) {
<button
mat-menu-item
(click)="toggleProjectVisibility(project.id)"
>
@if (!project.isHiddenFromMenu) {
<mat-icon>visibility</mat-icon>
} @else {
<mat-icon>visibility_off</mat-icon>
}
<span>{{ project.title }}</span>
</button>
}
</mat-menu>
<!--}-->

View file

@ -0,0 +1,133 @@
:host {
display: contents;
}
.g-multi-btn-wrapper {
display: flex;
align-items: center;
position: relative;
// Hide additional buttons by default on desktop
.additional-btns {
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
// Show additional buttons on hover (desktop)
&:hover .additional-btns {
opacity: 1;
visibility: visible;
}
}
// Always show buttons on mobile/touch devices
@media (hover: none) {
.additional-btns {
opacity: 1;
visibility: visible;
}
}
.additional-btns {
display: flex;
position: absolute;
right: 0;
top: 0;
bottom: 0;
}
.additional-btn {
flex-shrink: 0;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 4px !important;
margin-left: var(--s);
color: var(--sidebar-text-secondary);
background: transparent;
border: none;
border-radius: 0 !important; // Override Material Design rounded corners
cursor: pointer;
// Override Material Design button styles
&.mat-mdc-icon-button {
--mdc-icon-button-state-layer-size: 32px;
--mdc-icon-button-icon-size: 18px;
border-radius: 0 !important;
width: 32px !important;
height: 32px !important;
padding: 4px !important;
}
&:hover {
background-color: var(--sidebar-hover);
color: var(--sidebar-text);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.nav-children {
list-style: none;
padding: 0;
margin: 4px 0 0 0;
// Angular animations handle expand/collapse - no CSS transitions needed
}
.nav-child-item {
&.draggable {
cursor: grab;
// Remove default transitions for better performance
&:active {
cursor: grabbing;
}
&.cdk-drag-animating {
transition: transform 200ms cubic-bezier(0.25, 0.8, 0.25, 1);
}
}
&.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
transform: rotate(2deg);
opacity: 0.9;
// Use GPU acceleration
will-change: transform;
}
&.cdk-drag-placeholder {
opacity: 0.4;
background: var(--sidebar-hover);
border-radius: 4px;
// Minimize repaint
transform: translateZ(0);
}
}
// Drag performance optimizations - keep these for smooth drag operations
.cdk-drop-list-dragging .nav-child-item:not(.cdk-drag-placeholder) {
transition: transform 200ms cubic-bezier(0.25, 0.8, 0.25, 1);
will-change: transform;
}
// CompactMode state behaviors inherited from parent sidebar
:host-context(.nav-sidebar.compactMode) {
.g-multi-btn-wrapper {
justify-content: center;
.additional-btn {
display: none;
}
}
// Angular animations will handle expand/collapse behavior
// The service handles filtering to show only active items when collapsed
}

View file

@ -0,0 +1,88 @@
import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { MatTooltip } from '@angular/material/tooltip';
import { MatMenuModule } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { NavItemComponent } from '../nav-item/nav-item.component';
import { NavGroupItem, NavItem, NavWorkContextItem } from '../magic-side-nav.model';
import { DRAG_DELAY_FOR_TOUCH_LONGER } from '../../../app.constants';
import { IS_TOUCH_PRIMARY } from '../../../util/is-mouse-primary';
import { standardListAnimation } from '../../../ui/animations/standard-list.ani';
import { expandFadeAnimation } from '../../../ui/animations/expand.ani';
import { MagicNavConfigService } from '../magic-nav-config.service';
@Component({
selector: 'nav-list',
standalone: true,
imports: [
CommonModule,
MatIcon,
MatIconButton,
MatTooltip,
MatMenuModule,
TranslatePipe,
CdkDropList,
CdkDrag,
NavItemComponent,
],
templateUrl: './nav-list.component.html',
styleUrl: './nav-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [standardListAnimation, expandFadeAnimation],
})
export class NavSectionComponent {
private readonly _navConfigService = inject(MagicNavConfigService);
item = input.required<NavGroupItem>();
showLabels = input<boolean>(true);
isExpanded = input<boolean>(false);
activeWorkContextId = input<string | null>(null);
itemClick = output<NavItem>();
dragDrop = output<{
items: NavWorkContextItem[];
event: CdkDragDrop<string, string, NavWorkContextItem>;
}>();
readonly IS_TOUCH_PRIMARY = IS_TOUCH_PRIMARY;
readonly DRAG_DELAY_FOR_TOUCH_LONGER = DRAG_DELAY_FOR_TOUCH_LONGER;
// Access to service methods and data for visibility menu
readonly allProjectsExceptInbox = this._navConfigService.allProjectsExceptInbox;
onHeaderClick(): void {
this.itemClick.emit(this.item());
}
onChildClick(child: NavItem): void {
this.itemClick.emit(child);
}
toggleProjectVisibility(projectId: string): void {
this._navConfigService.toggleProjectVisibility(projectId);
}
onDrop(event: CdkDragDrop<string, string, NavWorkContextItem>): void {
// Early exit if no actual movement
if (
event.previousContainer !== event.container ||
event.currentIndex === event.previousIndex
) {
return;
}
// Filter work context items once
const workContextItems =
this.item().children?.filter(
(child): child is NavWorkContextItem => child.type === 'workContext',
) || [];
// Only emit if there are actually work context items to reorder
if (workContextItems.length > 0) {
this.dragDrop.emit({ items: workContextItems, event });
}
}
}

View file

@ -0,0 +1,34 @@
<li
class="nav-item"
role="listitem"
>
<div class="g-multi-btn-wrapper">
<nav-item
[container]="'group'"
[icon]="item().icon"
[svgIcon]="item().svgIcon"
[label]="item().label"
[showLabels]="showLabels()"
[menuTriggerFor]="menuRef"
></nav-item>
</div>
<mat-menu #menuRef="matMenu">
@for (child of item().children; track child.id) {
<button
mat-menu-item
(click)="onChildClick(child)"
>
@if (child['svgIcon']) {
<mat-icon
class="nav-icon"
[svgIcon]="child['svgIcon']"
></mat-icon>
} @else {
<mat-icon class="nav-icon">{{ child.icon }}</mat-icon>
}
<span>{{ child.label | translate }}</span>
</button>
}
</mat-menu>
</li>

View file

@ -0,0 +1,4 @@
/* Reuse existing nav-link and nav-icon classes from side nav styles */
:host {
display: contents;
}

View file

@ -0,0 +1,34 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatMenuModule } from '@angular/material/menu';
import { MatIcon } from '@angular/material/icon';
import { RouterModule } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { NavItem, NavMenuItem } from '../magic-side-nav.model';
import { NavItemComponent } from '../nav-item/nav-item.component';
@Component({
selector: 'nav-mat-menu',
standalone: true,
imports: [
CommonModule,
MatMenuModule,
MatIcon,
RouterModule,
TranslatePipe,
NavItemComponent,
],
templateUrl: './nav-mat-menu.component.html',
styleUrl: './nav-mat-menu.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NavMatMenuComponent {
item = input.required<NavMenuItem>();
showLabels = input<boolean>(true);
itemClick = output<NavItem>();
onChildClick(child: NavItem): void {
this.itemClick.emit(child);
}
}

View file

@ -1,14 +1,4 @@
<div class="wrapper">
@if (!isNavAlwaysVisible()) {
<button
(click)="layoutService.toggleSideNav()"
class="burger-trigger tour-burgerTrigger"
mat-icon-button
>
<mat-icon>menu</mat-icon>
</button>
}
<work-context-title
[title]="activeWorkContextTitle()"
[activeWorkContextTypeAndId]="activeWorkContextTypeAndId()"

View file

@ -52,23 +52,6 @@
}
}
.current-work-context-title {
font-size: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
cursor: pointer;
border-radius: var(--card-border-radius);
padding: var(--s) var(--s2) var(--s) var(--s);
@include mq(xs) {
padding-right: var(--s);
}
&:focus {
outline: 0;
}
}
.project-settings-btn {
display: none;
@include mq(xs) {
@ -268,7 +251,7 @@ button.isActive2 {
&::after {
border-radius: 4px;
box-shadow: 0 -2px 3px var(--separator-alpha);
background: var(--sidebar-bg);
background: var(--sidenav-bg);
content: '';
width: 100%;
position: absolute;

View file

@ -146,7 +146,6 @@ export class MainHeaderComponent implements OnDestroy {
this.workContextService.activeWorkContextTypeAndId$,
);
activeWorkContextTitle = toSignal(this.workContextService.activeWorkContextTitle$);
isNavAlwaysVisible = computed(() => this.layoutService.isNavAlwaysVisible());
currentTask = toSignal(this.taskService.currentTask$);
currentTaskId = this.taskService.currentTaskId;
pomodoroIsEnabled = toSignal(this.pomodoroService.isEnabled$);

View file

@ -47,7 +47,7 @@
// Active button styles
button.active {
background-color: var(--sidebar-bg);
background-color: var(--sidenav-bg);
&.isCustomized {
background-color: var(--c-accent);

View file

@ -72,8 +72,10 @@ import { map } from 'rxjs/operators';
cursor: pointer;
border-radius: var(--card-border-radius);
padding: var(--s) var(--s2) var(--s) var(--s);
padding-left: 54px;
@media (min-width: 600px) {
padding-left: 0;
padding-right: var(--s);
}

View file

@ -132,8 +132,8 @@ export class ShortcutService {
} else if (checkKeyCombo(ev, keys.showSearchBar)) {
this._router.navigate(['/search']);
ev.preventDefault();
} else if (checkKeyCombo(ev, keys.toggleSideNav)) {
this._layoutService.toggleSideNav();
} else if (checkKeyCombo(ev, keys.focusSideNav)) {
this._focusSideNav();
ev.preventDefault();
} else if (checkKeyCombo(ev, keys.addNewTask)) {
this._layoutService.showAddTaskBar();
@ -194,4 +194,37 @@ export class ShortcutService {
}
}
}
private _focusSideNav(): void {
console.log('FocusSideNav called');
// Very simple approach - just find the first nav-link and focus it
const firstNavLink = document.querySelector('.nav-sidebar .nav-link') as HTMLElement;
console.log('First nav link found:', !!firstNavLink, firstNavLink);
if (firstNavLink) {
// Make sure it's focusable
firstNavLink.setAttribute('tabindex', '0');
// Focus with a small delay to ensure DOM is ready
setTimeout(() => {
firstNavLink.focus();
console.log('Focused element. Active element is now:', document.activeElement);
console.log('Focus successful:', document.activeElement === firstNavLink);
}, 10);
} else {
// Fallback: try to focus the toggle button
const toggleBtn = document.querySelector(
'.nav-sidebar .sidebar-toggle',
) as HTMLElement;
console.log('Toggle button found:', !!toggleBtn, toggleBtn);
if (toggleBtn) {
toggleBtn.setAttribute('tabindex', '0');
setTimeout(() => {
toggleBtn.focus();
console.log('Focused toggle. Active element is now:', document.activeElement);
}, 10);
}
}
}
}

View file

@ -1,43 +0,0 @@
<div
class="planner-drag-place-holder-shared"
*cdkDragPlaceholder
></div>
<div
[style.background]="workContext().theme.primary"
class="color-bar"
></div>
<button
#routeBtn
[routerLink]="[type() === 'TAG' ? 'tag' : 'project', workContext().id, 'tasks']"
routerLinkActive="isActiveRoute"
mat-menu-item
>
<span class="badge">{{ nrOfOpenTasks() }}</span>
<mat-icon class="drag-handle">{{ workContext().icon || defaultIcon() }}</mat-icon>
<span class="text">{{ workContext().title }}</span>
</button>
<!--[matMenuTriggerFor]="contextMenuEl"-->
<button
#settingsBtn
class="additional-btn"
mat-icon-button
>
<mat-icon>more_vert</mat-icon>
</button>
<context-menu
[contextMenu]="contextMenu"
[rightClickTriggerEl]="routeBtn"
[leftClickTriggerEl]="settingsBtn"
></context-menu>
<ng-template #contextMenu>
<work-context-menu
[contextId]="workContext().id"
[contextType]="type()"
></work-context-menu
></ng-template>

View file

@ -1,101 +0,0 @@
@use '../../../../styles/_globals.scss' as *;
:host {
button .badge {
display: none;
}
&.hasTasks {
button .badge {
display: block;
z-index: 10;
position: absolute;
line-height: 1;
right: 100%;
text-align: center;
bottom: 6px;
font-size: 10px;
min-width: 18px;
padding: 0 4px 0;
border: 1px solid var(--extra-border-color);
border-radius: 12px;
margin-right: -49px;
// avoid affecting drag handle
pointer-events: none;
border-color: var(--extra-border-color);
background: var(--bg-lighter);
}
}
&.isHidden {
display: none !important;
}
// color bar left styles
.color-bar,
&.isActiveRoute:before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--s-half);
background-color: var(--c-primary);
}
.color-bar {
opacity: 0;
}
&.isActiveContext,
&:focus,
&:hover {
.color-bar {
opacity: 1;
}
}
}
:host-context(.cdk-drop-list-dragging) {
.color-bar {
opacity: 0 !important;
}
}
:host.isActiveContext button {
font-weight: normal;
&.isActiveRoute {
font-weight: bold;
color: var(--palette-primary-800);
mat-icon {
color: var(--palette-primary-800);
}
}
}
body.isDarkTheme :host.isActiveContext button.isActiveRoute {
color: var(--c-primary);
mat-icon {
color: var(--c-primary);
}
}
.drag-handle {
/* Firefox 1.5-26 */
position: relative;
@include grabCursor();
&:after {
content: '';
position: absolute;
top: calc(-1 * var(--s));
left: calc(-1 * var(--s));
right: calc(-1 * var(--s));
bottom: calc(-1 * var(--s));
}
}

View file

@ -1,23 +0,0 @@
// import { ComponentFixture, TestBed } from '@angular/core/testing';
//
// import { SideNavItemComponent } from './side-nav-item.component';
//
// describe('SideNavItemComponent', () => {
// let component: SideNavItemComponent;
// let fixture: ComponentFixture<SideNavItemComponent>;
//
// beforeEach(async () => {
// await TestBed.configureTestingModule({
// imports: [SideNavItemComponent]
// })
// .compileComponents();
//
// fixture = TestBed.createComponent(SideNavItemComponent);
// component = fixture.componentInstance;
// fixture.detectChanges();
// });
//
// it('should create', () => {
// expect(component).toBeTruthy();
// });
// });

View file

@ -1,82 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
HostBinding,
inject,
input,
viewChild,
} from '@angular/core';
import { RouterLink, RouterModule } from '@angular/router';
import {
WorkContextCommon,
WorkContextType,
} from '../../../features/work-context/work-context.model';
import { Project } from '../../../features/project/project.model';
import { WorkContextMenuComponent } from '../../work-context-menu/work-context-menu.component';
import { ContextMenuComponent } from '../../../ui/context-menu/context-menu.component';
import { CdkDragPlaceholder } from '@angular/cdk/drag-drop';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuItem } from '@angular/material/menu';
import { toSignal } from '@angular/core/rxjs-interop';
import { selectAllDoneIds } from '../../../features/tasks/store/task.selectors';
import { Store } from '@ngrx/store';
@Component({
selector: 'side-nav-item',
imports: [
RouterLink,
RouterModule,
WorkContextMenuComponent,
ContextMenuComponent,
CdkDragPlaceholder,
MatIconButton,
MatIcon,
MatMenuItem,
],
templateUrl: './side-nav-item.component.html',
styleUrl: './side-nav-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'g-multi-btn-wrapper' },
standalone: true,
})
export class SideNavItemComponent {
private readonly _store = inject(Store);
workContext = input.required<WorkContextCommon>();
type = input.required<WorkContextType>();
defaultIcon = input.required<string>();
activeWorkContextId = input.required<string>();
allUndoneTaskIds = toSignal(this._store.select(selectAllDoneIds), { initialValue: [] });
nrOfOpenTasks = computed<number>(() => {
// const allUndoneTasks
const allUndoneTaskIds = this.allUndoneTaskIds();
return this.workContext().taskIds.filter((tid) => !allUndoneTaskIds.includes(tid))
.length;
});
readonly routeBtn = viewChild.required('routeBtn', { read: ElementRef });
@HostBinding('class.hasTasks')
get workContextHasTasks(): boolean {
return this.workContext().taskIds.length > 0;
}
@HostBinding('class.isActiveContext')
get isActiveContext(): boolean {
return this.workContext().id === this.activeWorkContextId();
}
@HostBinding('class.isHidden')
get isHidden(): boolean {
return !!(this.workContext() as Project)?.isHiddenFromMenu;
}
focus(): void {
this.routeBtn().nativeElement.focus();
}
}

View file

@ -1,309 +0,0 @@
<section class="main">
@if (workContextService.mainWorkContext$ | async; as mainContext) {
<side-nav-item
#menuEntry
[type]="WorkContextType.TAG"
[defaultIcon]="mainContext.icon"
[workContext]="mainContext"
[activeWorkContextId]="activeWorkContextId"
></side-nav-item>
}
@let inboxContext = workContextService.inboxWorkContext$ | async;
@if (inboxContext) {
<side-nav-item
#menuEntry
[type]="WorkContextType.PROJECT"
[defaultIcon]="inboxContext.icon"
[workContext]="inboxContext"
[activeWorkContextId]="activeWorkContextId"
></side-nav-item>
}
<button
#menuEntry
class="route-link"
mat-menu-item
routerLink="schedule"
routerLinkActive="isActiveRoute"
>
<mat-icon svgIcon="early_on"></mat-icon>
<span class="text">{{ T.MH.SCHEDULE | translate }}</span>
</button>
<button
#menuEntry
class="route-link"
mat-menu-item
routerLink="planner"
routerLinkActive="isActiveRoute"
>
<mat-icon>edit_calendar</mat-icon>
<span class="text">{{ T.MH.PLANNER | translate }}</span>
</button>
<button
#menuEntry
class="route-link"
mat-menu-item
routerLink="boards"
routerLinkActive="isActiveRoute"
>
<mat-icon>grid_view</mat-icon>
<span class="text">{{ T.MH.BOARDS | translate }}</span>
</button>
</section>
@if (nonHiddenProjects(); as projectList) {
<section class="projects tour-projects">
<div class="g-multi-btn-wrapper e2e-projects-btn">
<button
#menuEntry
#projectExpandBtn
(click)="toggleExpandProjects()"
(keydown)="toggleExpandProjectsLeftRight($event)"
[class.isExpanded]="isProjectsExpanded()"
class="expand-btn"
mat-menu-item
>
<mat-icon>expand_more</mat-icon>
<span class="title">{{ T.MH.PROJECTS | translate }}</span>
</button>
<!-- <button-->
<!-- #projectVisibilityBtn-->
<!-- mat-icon-button-->
<!-- class="additional-btn"-->
<!-- >-->
<!-- <mat-icon>visibility</mat-icon>-->
<!-- </button>-->
<button
[style.display]="allNonInboxProjects().length ? '' : 'none'"
class="additional-btn"
#projectExpandBtn2
mat-icon-button
>
<mat-icon>visibility_off</mat-icon>
</button>
<button
(click)="addProject()"
class="additional-btn e2e-add-project-btn"
mat-icon-button
[matTooltip]="T.MH.CREATE_PROJECT | translate"
>
<mat-icon>add</mat-icon>
</button>
</div>
<!-- [leftClickTriggerEl]="projectVisibilityBtn"-->
<context-menu
[contextMenu]="contextMenu"
[leftClickTriggerEl]="projectExpandBtn2"
[rightClickTriggerEl]="projectExpandBtn"
></context-menu>
<ng-template #contextMenu>
@for (project of allNonInboxProjects(); track project.id) {
<button
mat-menu-item
(click)="toggleProjectVisibility(project)"
>
@if (!project.isHiddenFromMenu) {
<mat-icon>visibility</mat-icon>
}
@if (project.isHiddenFromMenu) {
<mat-icon>visibility_off</mat-icon>
}
{{ project.title }}
</button>
}
</ng-template>
<div
[@standardList]="projectList?.length"
cdkDropList
(cdkDropListDropped)="dropOnProjectList(projectList, $event)"
>
@for (project of projectList; track project.id) {
<side-nav-item
#menuEntry
cdkDrag
[cdkDragData]="project"
[cdkDragStartDelay]="IS_TOUCH_PRIMARY ? DRAG_DELAY_FOR_TOUCH_LONGER : 0"
(keydown)="checkFocusProject($event)"
[workContext]="project"
[type]="WorkContextType.PROJECT"
[defaultIcon]="'folder_special'"
[activeWorkContextId]="activeWorkContextId"
></side-nav-item>
}
</div>
@if (!projectList.length && isProjectsExpanded()) {
<div class="no-tags-info">{{ T.MH.NO_PROJECT_INFO | translate }}</div>
}
</section>
}
@if (tagList(); as tagList) {
<section class="tags">
<button
#menuEntry
#tagExpandBtn
(click)="toggleExpandTags()"
(keydown)="toggleExpandTagsLeftRight($event)"
[class.isExpanded]="isTagsExpanded()"
class="expand-btn"
mat-menu-item
>
<span class="title">{{ T.MH.TAGS | translate }}</span>
<mat-icon>expand_more</mat-icon>
</button>
<div
[@standardList]="tagList?.length"
cdkDropList
(cdkDropListDropped)="dropOnTagList(tagList, $event)"
>
@for (tag of tagList; track tag.id) {
<side-nav-item
#menuEntry
cdkDrag
[cdkDragData]="tag"
[cdkDragStartDelay]="IS_TOUCH_PRIMARY ? DRAG_DELAY_FOR_TOUCH_LONGER : 0"
(keydown)="checkFocusTag($event)"
[workContext]="tag"
[type]="WorkContextType.TAG"
[defaultIcon]="'label'"
[activeWorkContextId]="activeWorkContextId"
></side-nav-item>
}
</div>
@if (!tagList.length && isTagsExpanded()) {
<div class="no-tags-info">{{ T.MH.NO_TAG_INFO | translate }}</div>
}
<!-- <button (click)="addTag()"-->
<!-- *ngIf="isTagsExpanded"-->
<!-- #menuEntry mat-menu-item>-->
<!-- <mat-icon>add</mat-icon>-->
<!-- {{T.MH.CREATE_TAG|translate}}-->
<!-- </button>-->
</section>
}
<!--<section class="other-task-level-section">-->
<!--</section>-->
<section class="app">
<button
#menuEntry
class="route-link"
mat-menu-item
routerLink="search"
routerLinkActive="isActiveRoute"
>
<mat-icon>search</mat-icon>
<span class="text">{{ T.MH.SEARCH | translate }}</span>
</button>
<button
#menuEntry
class="route-link"
mat-menu-item
routerLink="scheduled-list"
routerLinkActive="isActiveRoute"
>
<!-- <mat-icon>history</mat-icon>-->
<mat-icon>list</mat-icon>
<span class="text">{{ T.MH.ALL_PLANNED_LIST | translate }}</span>
</button>
<button
#menuEntry
[mat-menu-trigger-for]="helpMenu"
class="route-link"
mat-menu-item
>
<mat-icon>help_center</mat-icon>
<span class="text">{{ T.MH.HELP | translate }}</span>
</button>
<mat-menu #helpMenu="matMenu">
<ng-template matMenuContent>
<a
mat-menu-item
href="https://github.com/johannesjo/super-productivity/blob/master/README.md#question-how-to-use-it"
target="_blank"
>
<mat-icon>help_center</mat-icon>
<span class="text">{{ T.MH.HM.GET_HELP_ONLINE | translate }}</span>
</a>
<a
class="route-link"
mat-menu-item
[href]="getGithubErrorUrl()"
target="_blank"
>
<mat-icon>bug_report</mat-icon>
<span class="text">{{ T.MH.HM.REPORT_A_PROBLEM | translate }}</span>
</a>
<a
mat-menu-item
href="https://github.com/johannesjo/super-productivity/blob/master/CONTRIBUTING.md"
target="_blank"
>
<mat-icon>volunteer_activism</mat-icon>
<span class="text">{{ T.MH.HM.CONTRIBUTE | translate }}</span>
</a>
<a
mat-menu-item
href="https://www.reddit.com/r/superProductivity/"
target="_blank"
>
<mat-icon>forum</mat-icon>
<span class="text">{{ T.MH.HM.REDDIT_COMMUNITY | translate }}</span>
</a>
<button
(click)="startTour(TourId.Welcome)"
mat-menu-item
>
<mat-icon>directions</mat-icon>
<span class="text">{{ T.MH.HM.START_WELCOME | translate }}</span>
</button>
@if (IS_MOUSE_PRIMARY) {
<button
(click)="startTour(TourId.KeyboardNav)"
mat-menu-item
>
<mat-icon>directions</mat-icon>
<span class="text">{{ T.MH.HM.KEYBOARD | translate }}</span>
</button>
}
<button
(click)="startTour(TourId.Sync)"
mat-menu-item
>
<mat-icon>directions</mat-icon>
<span class="text">{{ T.MH.HM.SYNC | translate }}</span>
</button>
<button
(click)="startTour(TourId.IssueProviders)"
mat-menu-item
>
<mat-icon>directions</mat-icon>
<span class="text">{{ T.MH.HM.CALENDARS | translate }}</span>
</button>
</ng-template>
</mat-menu>
<plugin-menu></plugin-menu>
<button
#menuEntry
class="route-link tour-settingsMenuBtn"
mat-menu-item
routerLink="config"
routerLinkActive="isActiveRoute"
>
<mat-icon>settings</mat-icon>
<span class="text">{{ T.MH.SETTINGS | translate }}</span>
</button>
</section>
<!-- NOTE: needs to be here for mat menu styles always to be loaded -->
<mat-menu></mat-menu>

View file

@ -1,177 +0,0 @@
@use '../../../styles/_globals.scss' as *;
// MAIN LAYOUT
// -----------
:host {
flex-grow: 1;
max-width: var(--side-nav-width);
width: var(--side-nav-width);
display: flex;
flex-direction: column;
min-height: 100vh;
// mobile viewport bug fix
min-height: -webkit-fill-available;
&.minimal-nav {
max-width: 42px;
@include mq(xxs) {
max-width: 52px;
}
.expand-btn mat-icon {
margin-left: 0;
}
.expand-btn .title {
display: none;
}
::ng-deep {
@include mq(xxs, max) {
.badge {
margin-right: -40px !important;
}
}
.additional-btn {
display: none !important;
}
.mat-mdc-menu-item {
padding: 0 8px;
@include mq(xxs) {
padding: 0 13px;
}
}
}
}
a,
button {
height: 44px !important;
line-height: 44px !important;
min-height: 44px !important;
}
a {
color: rgba(var(--palette-foreground-text), var(--palette-foreground-text-alpha));
}
}
@media (min-width: var(--side-nav-width-switch-l)) {
:host {
max-width: var(--side-nav-width-l);
width: var(--side-nav-width-l);
}
}
@media (max-width: 500px) {
:host {
max-width: var(--side-nav-width-mobile);
width: var(--side-nav-width-mobile);
}
}
section {
display: flex;
flex-direction: column;
}
.main {
margin-bottom: var(--s);
padding-bottom: var(--s);
:host-context(.isMac.isElectron) & {
padding-top: var(--mac-title-bar-padding);
}
&:after {
bottom: 0;
}
}
.app {
margin-top: auto;
padding-top: var(--s);
&:after {
top: 0;
}
}
.other-task-level-section,
.main,
.tags,
.app {
position: relative;
&:after {
border-bottom: 1px solid var(--divider-color);
content: '';
position: absolute;
left: var(--s2);
right: var(--s2);
height: 0;
}
}
.tags,
.projects {
margin-bottom: var(--s);
padding-bottom: var(--s);
}
// LIST-ITEMS
// ----------
.route-link.isActiveRoute {
background-color: mat-css-color-primary(500, 0.04);
font-weight: bold;
}
.route-link.isActiveRoute,
.route-link.isActiveRoute mat-icon {
color: var(--palette-primary-800);
}
body.isDarkTheme .route-link.isActiveRoute,
body.isDarkTheme .route-link.isActiveRoute mat-icon {
color: var(--c-primary);
}
// OTHER-ITEMS
// ----------
.expand-btn {
display: flex;
align-items: center;
font-weight: bold;
font-size: 12px;
opacity: 0.7;
&:focus,
&:hover {
> mat-icon:last-of-type {
opacity: 1;
}
}
> mat-icon:last-of-type {
margin-right: 0;
//margin-left: -10px;
opacity: 0.4;
transition: var(--transition-standard);
}
&.isExpanded ::ng-deep mat-icon:last-of-type {
transform: rotate(180deg);
}
}
.no-tags-info {
padding: 4px 16px;
font-style: italic;
opacity: 0.7;
}

View file

@ -1,25 +0,0 @@
// import { async, ComponentFixture, TestBed } from '@angular/core/testing';
//
// import { SideNavComponent } from './side-nav.component';
//
// describe('ProjectListComponent', () => {
// let component: SideNavComponent;
// let fixture: ComponentFixture<SideNavComponent>;
//
// beforeEach(async(() => {
// TestBed.configureTestingModule({
// declarations: [ SideNavComponent ]
// })
// .compileComponents();
// }));
//
// beforeEach(() => {
// fixture = TestBed.createComponent(SideNavComponent);
// component = fixture.componentInstance;
// fixture.detectChanges();
// });
//
// it('should create', () => {
// expect(component).toBeTruthy();
// });
// });

View file

@ -1,321 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
HostBinding,
HostListener,
inject,
OnDestroy,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import { ProjectService } from '../../features/project/project.service';
import { T } from '../../t.const';
import { DialogCreateProjectComponent } from '../../features/project/dialogs/create-project/dialog-create-project.component';
import { Project } from '../../features/project/project.model';
import { MatDialog } from '@angular/material/dialog';
import { DRAG_DELAY_FOR_TOUCH_LONGER } from '../../app.constants';
import { Subscription } from 'rxjs';
import { WorkContextService } from '../../features/work-context/work-context.service';
import { standardListAnimation } from '../../ui/animations/standard-list.ani';
import { first } from 'rxjs/operators';
import { TagService } from '../../features/tag/tag.service';
import { Tag } from '../../features/tag/tag.model';
import { WorkContextType } from '../../features/work-context/work-context.model';
import { expandFadeAnimation } from '../../ui/animations/expand.ani';
import { FocusKeyManager } from '@angular/cdk/a11y';
import {
MatMenu,
MatMenuContent,
MatMenuItem,
MatMenuTrigger,
} from '@angular/material/menu';
import { LayoutService } from '../layout/layout.service';
import { TaskService } from '../../features/tasks/task.service';
import { LS } from '../../core/persistence/storage-keys.const';
import { TODAY_TAG } from '../../features/tag/tag.const';
import { TourId } from '../../features/shepherd/shepherd-steps.const';
import { ShepherdService } from '../../features/shepherd/shepherd.service';
import { getGithubErrorUrl } from '../../core/error-handler/global-error-handler.util';
import { IS_MOUSE_PRIMARY, IS_TOUCH_PRIMARY } from '../../util/is-mouse-primary';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { moveItemBeforeItem } from '../../util/move-item-before-item';
import { Store } from '@ngrx/store';
import {
selectAllProjectsExceptInbox,
selectUnarchivedHiddenProjectIds,
selectUnarchivedVisibleProjects,
} from '../../features/project/store/project.selectors';
import { SideNavItemComponent } from './side-nav-item/side-nav-item.component';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { MatTooltip } from '@angular/material/tooltip';
import { ContextMenuComponent } from '../../ui/context-menu/context-menu.component';
import { TranslatePipe } from '@ngx-translate/core';
import { AsyncPipe } from '@angular/common';
import { toggleHideFromMenu } from '../../features/project/store/project.actions';
import { PluginMenuComponent } from '../../plugins/ui/plugin-menu.component';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'side-nav',
templateUrl: './side-nav.component.html',
styleUrls: ['./side-nav.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [standardListAnimation, expandFadeAnimation],
imports: [
SideNavItemComponent,
MatMenuItem,
RouterLink,
RouterLinkActive,
MatIcon,
MatIconButton,
MatTooltip,
ContextMenuComponent,
CdkDropList,
CdkDrag,
MatMenuTrigger,
MatMenu,
MatMenuContent,
TranslatePipe,
AsyncPipe,
PluginMenuComponent,
],
})
export class SideNavComponent implements OnDestroy {
readonly tagService = inject(TagService);
readonly projectService = inject(ProjectService);
readonly workContextService = inject(WorkContextService);
private readonly _matDialog = inject(MatDialog);
private readonly _layoutService = inject(LayoutService);
private readonly _taskService = inject(TaskService);
private readonly _shepherdService = inject(ShepherdService);
private readonly _globalConfigService = inject(GlobalConfigService);
private readonly _store = inject(Store);
navEntries = viewChildren<MatMenuItem>('menuEntry');
IS_MOUSE_PRIMARY = IS_MOUSE_PRIMARY;
IS_TOUCH_PRIMARY = IS_TOUCH_PRIMARY;
DRAG_DELAY_FOR_TOUCH_LONGER = DRAG_DELAY_FOR_TOUCH_LONGER;
keyboardFocusTimeout?: number;
projectExpandBtn = viewChild('projectExpandBtn', { read: ElementRef });
isProjectsExpanded = signal(this.fetchProjectListState());
allNonInboxProjects = toSignal(this._store.select(selectAllProjectsExceptInbox), {
initialValue: [],
});
private _allVisibleProjects = toSignal(
this._store.select(selectUnarchivedVisibleProjects),
{ initialValue: [] },
);
private _activeWorkContextId = toSignal(this.workContextService.activeWorkContextId$, {
initialValue: null,
});
nonHiddenProjects = computed(() => {
const isExpanded = this.isProjectsExpanded();
const projects = this._allVisibleProjects();
if (isExpanded) {
return projects;
}
const activeId = this._activeWorkContextId();
return projects.filter((p) => p.id === activeId);
});
tagExpandBtn = viewChild('tagExpandBtn', { read: ElementRef });
isTagsExpanded = signal(this.fetchTagListState());
private _tagListToDisplay = toSignal(this.tagService.tagsNoMyDayAndNoList$, {
initialValue: [],
});
tagList = computed(() => {
const isExpanded = this.isTagsExpanded();
const tags = this._tagListToDisplay();
if (isExpanded) {
return tags;
}
const activeId = this._activeWorkContextId();
return tags.filter((t) => t.id === activeId);
});
T: typeof T = T;
activeWorkContextId?: string | null;
WorkContextType: typeof WorkContextType = WorkContextType;
TourId: typeof TourId = TourId;
private keyManager?: FocusKeyManager<MatMenuItem>;
private _subs: Subscription = new Subscription();
private _cachedIssueUrl?: string;
@HostBinding('class') get cssClass(): string {
return this._globalConfigService.cfg()?.misc.isUseMinimalNav ? 'minimal-nav' : '';
}
constructor() {
this._subs.add(
this.workContextService.activeWorkContextId$.subscribe(
(id) => (this.activeWorkContextId = id),
),
);
// Effect to handle side nav visibility changes
effect(() => {
const isShow = this._layoutService.isShowSideNav();
const navEntries = this.navEntries();
if (navEntries && isShow) {
this.keyManager = new FocusKeyManager<MatMenuItem>(
navEntries,
).withVerticalOrientation(true);
window.clearTimeout(this.keyboardFocusTimeout);
this.keyboardFocusTimeout = window.setTimeout(() => {
this.keyManager?.setFirstItemActive();
}, 100);
// this.keyManager.change.subscribe((v) => Log.log('this.keyManager.change', v));
} else if (!isShow) {
this._taskService.focusFirstTaskIfVisible();
}
});
}
@HostListener('keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
this.keyManager?.onKeydown(event);
}
ngOnDestroy(): void {
this._subs.unsubscribe();
window.clearTimeout(this.keyboardFocusTimeout);
}
addProject(): void {
this._matDialog.open(DialogCreateProjectComponent, {
restoreFocus: true,
});
}
fetchProjectListState(): boolean {
return localStorage.getItem(LS.IS_PROJECT_LIST_EXPANDED) === 'true';
}
private _storeProjectListState(isExpanded: boolean): void {
this.isProjectsExpanded.set(isExpanded);
localStorage.setItem(LS.IS_PROJECT_LIST_EXPANDED, isExpanded.toString());
}
fetchTagListState(): boolean {
return localStorage.getItem(LS.IS_TAG_LIST_EXPANDED) === 'true';
}
storeTagListState(isExpanded: boolean): void {
this.isTagsExpanded.set(isExpanded);
localStorage.setItem(LS.IS_TAG_LIST_EXPANDED, isExpanded.toString());
}
toggleExpandProjects(): void {
const newState: boolean = !this.isProjectsExpanded();
this._storeProjectListState(newState);
}
toggleExpandProjectsLeftRight(ev: KeyboardEvent): void {
if (ev.key === 'ArrowLeft' && this.isProjectsExpanded()) {
this._storeProjectListState(false);
} else if (ev.key === 'ArrowRight' && !this.isProjectsExpanded()) {
this._storeProjectListState(true);
}
}
checkFocusProject(ev: KeyboardEvent): void {
if (ev.key === 'ArrowLeft' && this.projectExpandBtn()?.nativeElement) {
const targetIndex = this.navEntries().findIndex((value) => {
return (
typeof value._getHostElement === 'function' &&
value._getHostElement() === this.projectExpandBtn()?.nativeElement
);
});
if (targetIndex) {
this.keyManager?.setActiveItem(targetIndex);
}
}
}
toggleExpandTags(): void {
const newState: boolean = !this.isTagsExpanded();
this.storeTagListState(newState);
}
toggleExpandTagsLeftRight(ev: KeyboardEvent): void {
if (ev.key === 'ArrowLeft' && this.isTagsExpanded()) {
this.storeTagListState(false);
} else if (ev.key === 'ArrowRight' && !this.isTagsExpanded()) {
this.storeTagListState(true);
}
}
checkFocusTag(ev: KeyboardEvent): void {
if (ev.key === 'ArrowLeft' && this.tagExpandBtn()?.nativeElement) {
const targetIndex = this.navEntries().findIndex((value) => {
return (
typeof value._getHostElement === 'function' &&
value._getHostElement() === this.tagExpandBtn()?.nativeElement
);
});
if (targetIndex) {
this.keyManager?.setActiveItem(targetIndex);
}
}
}
startTour(id: TourId): void {
this._shepherdService.show(id);
}
getGithubErrorUrl(): string {
if (!this._cachedIssueUrl) {
this._cachedIssueUrl = getGithubErrorUrl('', undefined, true);
}
return this._cachedIssueUrl;
}
toggleProjectVisibility(project: Project): void {
this._store.dispatch(toggleHideFromMenu({ id: project.id }));
}
async dropOnProjectList(
allItems: Project[],
ev: CdkDragDrop<string, string, Project>,
): Promise<void> {
if (ev.previousContainer === ev.container && ev.currentIndex !== ev.previousIndex) {
const tag = ev.item.data;
const allIds = allItems.map((p) => p.id);
const targetTagId = allIds[ev.currentIndex] as string;
if (targetTagId) {
const hiddenIds = await this._store
.select(selectUnarchivedHiddenProjectIds)
.pipe(first())
.toPromise();
const newIds = [...moveItemBeforeItem(allIds, tag.id, targetTagId), ...hiddenIds];
this.projectService.updateOrder(newIds);
}
}
}
dropOnTagList(allItems: Tag[], ev: CdkDragDrop<string, string, Tag>): void {
if (ev.previousContainer === ev.container && ev.currentIndex !== ev.previousIndex) {
const tag = ev.item.data;
const allIds = allItems.map((p) => p.id);
const targetTagId = allIds[ev.currentIndex] as string;
if (targetTagId) {
// special today list should always be first
const newIds = [TODAY_TAG.id, ...moveItemBeforeItem(allIds, tag.id, targetTagId)];
this.tagService.updateOrder(newIds);
}
}
}
}

View file

@ -48,6 +48,10 @@ export enum LS {
DONE_TASKS_HIDDEN = 'DONE_TASKS_HIDDEN',
LATER_TODAY_TASKS_HIDDEN = 'LATER_TODAY_TASKS_HIDDEN',
OVERDUE_TASKS_HIDDEN = 'OVERDUE_TASKS_HIDDEN',
// Magic side nav
NAV_SIDEBAR_EXPANDED = 'SUP_NAV_SIDEBAR_EXPANDED',
NAV_SIDEBAR_WIDTH = 'SUP_NAV_SIDEBAR_WIDTH',
}
// SESSION STORAGE

View file

@ -98,7 +98,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
openProjectNotes: 'Shift+N',
toggleTaskViewCustomizerPanel: 'c',
toggleIssuePanel: 'p',
toggleSideNav: 'Shift+D',
focusSideNav: 'Shift+D',
showHelp: '?',
showSearchBar: 'Shift+F',
toggleBacklog: 'b',

View file

@ -77,7 +77,7 @@ export const KEYBOARD_SETTINGS_FORM_CFG: ConfigFormSection<KeyboardConfig> = {
},
},
{
key: 'toggleSideNav',
key: 'focusSideNav',
type: 'keyboard',
templateOptions: {
label: T.GCF.KEYBOARD.TOGGLE_SIDE_NAV,

View file

@ -15,7 +15,7 @@ export type KeyboardConfig = Readonly<{
showHelp?: string | null;
showSearchBar?: string | null;
addNewNote?: string | null;
toggleSideNav?: string | null;
focusSideNav?: string | null;
openProjectNotes?: string | null;
toggleTaskViewCustomizerPanel?: string | null;
toggleIssuePanel?: string | null;

View file

@ -436,16 +436,26 @@ export const SHEPHERD_STEPS = (
text: 'Open the menu (<span class="material-icons">menu</span>)',
beforeShowPromise: () => {
// If nav is always visible, skip this step
if (layoutService.isNavAlwaysVisible()) {
if (layoutService.isMobileNav()) {
setTimeout(() => shepherdService.next(), 0);
return Promise.resolve();
}
return Promise.resolve();
},
when: nextOnObs(
layoutService.isShowSideNav$.pipe(filter((v) => !!v)),
shepherdService,
),
when: {
show: () => {
// If nav is always visible, skip immediately
if (layoutService.isMobileNav()) {
setTimeout(() => shepherdService.next(), 0);
} else {
// For mobile, wait for the burger button to be clicked
// We'll use a simple timeout or manual next for now
// since the mobile nav opening doesn't have an observable
// TODO better implementation
setTimeout(() => shepherdService.next(), 8000);
}
},
},
},
// ------------------------------
@ -491,16 +501,24 @@ export const SHEPHERD_STEPS = (
beforeShowPromise: () => {
return router.navigate(['']).then(() => {
// If nav is always visible, skip this step
if (layoutService.isNavAlwaysVisible()) {
if (layoutService.isMobileNav()) {
setTimeout(() => shepherdService.next(), 0);
}
});
},
text: 'Open the menu (<span class="material-icons">menu</span>)',
when: nextOnObs(
layoutService.isShowSideNav$.pipe(filter((v) => !!v)),
shepherdService,
),
when: {
show: () => {
// If nav is always visible, skip immediately
if (layoutService.isMobileNav()) {
setTimeout(() => shepherdService.next(), 0);
} else {
// For mobile, wait for the burger button to be clicked
// TODO better implementation
setTimeout(() => shepherdService.next(), 8000);
}
},
},
},
{
title: 'Configure Sync',

View file

@ -20,11 +20,9 @@ import {
hideAddTaskBar,
hideNonTaskSidePanelContent,
hidePluginPanel,
hideSideNav,
showAddTaskBar,
toggleIssuePanel,
toggleShowNotes,
toggleSideNav,
} from '../core-ui/layout/store/layout.actions';
/**
@ -36,8 +34,6 @@ export const ALLOWED_PLUGIN_ACTIONS = [
// Layout
showAddTaskBar,
hideAddTaskBar,
hideSideNav,
toggleSideNav,
toggleShowNotes,
hideNonTaskSidePanelContent,
toggleIssuePanel,

108
src/app/util/ls-util.ts Normal file
View file

@ -0,0 +1,108 @@
/**
* localStorage utility functions for Super Productivity
*/
/**
* Check if a key exists in localStorage
*/
export const lsHasKey = (key: string): boolean => {
return localStorage.getItem(key) !== null;
};
/**
* Clear localStorage completely
*/
export const clearLS = (): void => {
localStorage.clear();
};
/**
* Get item from localStorage with default value
*/
export const lsGetItem = <T = string>(
key: string,
defaultValue?: T,
): T | string | null => {
const item = localStorage.getItem(key);
return item ?? defaultValue ?? null;
};
/**
* Get item from localStorage and parse as number, with optional default
*/
export const lsGetNumber = (key: string, defaultValue = 0): number => {
const item = localStorage.getItem(key);
return item ? +item : defaultValue;
};
/**
* Get item from localStorage and parse as boolean, with optional default
*/
export const lsGetBoolean = (key: string, defaultValue = false): boolean => {
const item = localStorage.getItem(key);
if (item === null) return defaultValue;
return item === 'true';
};
/**
* Get item from localStorage and parse as JSON, with optional default
*/
export const lsGetJSON = <T>(key: string, defaultValue?: T): T | null => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : (defaultValue ?? null);
} catch {
return defaultValue ?? null;
}
};
/**
* Set item in localStorage
*/
export const lsSetItem = (key: string, value: string | number | boolean): void => {
localStorage.setItem(key, String(value));
};
/**
* Set item in localStorage as JSON
*/
export const lsSetJSON = (key: string, value: unknown): void => {
localStorage.setItem(key, JSON.stringify(value));
};
/**
* Remove item from localStorage
*/
export const lsRemoveItem = (key: string): void => {
localStorage.removeItem(key);
};
/**
* Remove multiple items from localStorage
*/
export const lsRemoveItems = (keys: string[]): void => {
keys.forEach((key) => localStorage.removeItem(key));
};
/**
* Get boolean value from localStorage with fallback
*/
export const readBoolLS = (key: string, fallback: boolean): boolean => {
const v = localStorage.getItem(key);
return v === null ? fallback : v === 'true';
};
/**
* Get number from localStorage with bounds checking
*/
export const readNumberLSBounded = (
key: string,
min: number,
max: number,
): number | null => {
const v = localStorage.getItem(key);
if (v == null) return null;
const n = parseInt(v, 10);
if (Number.isNaN(n)) return null;
return Math.max(min, Math.min(max, n));
};

View file

@ -1681,7 +1681,7 @@
"TOGGLE_BOOKMARKS": "Show/Hide Bookmark Bar",
"TOGGLE_ISSUE_PANEL": "Show/Hide Issue Panel",
"TOGGLE_PLAY": "Start/Stop Task",
"TOGGLE_SIDE_NAV": "Show & Focus/Hide Sidenav",
"TOGGLE_SIDE_NAV": "Focus Sidenav",
"TOGGLE_TASK_VIEW_CUSTOMIZER_PANEL": "Toggle Filter/Group/Sort Panel",
"TRIGGER_SYNC": "Trigger sync (if configured)",
"ZOOM_DEFAULT": "Zoom default (Desktop only)",

View file

@ -1,7 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="#">
<path d="M680-120q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-280q-13 0-21.5-8.5T650-430v-20q0-13 8.5-21.5T680-480q13 0 21.5 8.5T710-450v20q0 13-8.5 21.5T680-400Zm0 320q13 0 21.5 8.5T710-50v20q0 13-8.5 21.5T680 0q-13 0-21.5-8.5T650-30v-20q0-13 8.5-21.5T680-80Zm114-274q-9-9-9-21.5t9-21.5l14-14q9-9 21-8.5t21 9.5q9 9 9 21t-9 21l-14 14q-9 9-21 9t-21-9ZM567-127q9 9 9 21t-9 21l-15 15q-9 9-21 9t-21-9q-9-9-9-21t9-21l15-15q9-9 21-9t21 9Zm273-113q0-13 8.5-21.5T870-270h20q13 0 21.5 8.5T920-240q0 13-8.5 21.5T890-210h-20q-13 0-21.5-8.5T840-240Zm-320 0q0 13-8.5 21.5T490-210h-20q-13 0-21.5-8.5T440-240q0-13 8.5-21.5T470-270h20q13 0 21.5 8.5T520-240Zm274 113q9-9 21-8.5t21 8.5l15 14q9 9 9 21t-9 21q-9 9-21.5 9T808-71l-14-14q-8-9-8.5-21t8.5-21ZM567-354q-9 9-21.5 9t-21.5-9l-14-14q-8-9-8.5-21t8.5-21q9-9 21-8.5t21 8.5l15 14q9 9 9 21t-9 21ZM200-80q-33 0-56.5-23.5T120-160v-560q0-33 23.5-56.5T200-800h40v-40q0-17 11.5-28.5T280-880q17 0 28.5 11.5T320-840v40h320v-40q0-17 11.5-28.5T680-880q17 0 28.5 11.5T720-840v40h40q33 0 56.5 23.5T840-720v120q0 17-11.5 28.5T800-560H200v400h120q17 0 28.5 11.5T360-120q0 17-11.5 28.5T320-80H200Zm0-560h560v-80H200v80Zm0 0v-80 80Z" />
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 -960 960 960">
<g transform="translate(480,-480) scale(0.80) translate(-480,480)">
<path fill="currentColor" d="M680-120q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-280q-13 0-21.5-8.5T650-430v-20q0-13 8.5-21.5T680-480q13 0 21.5 8.5T710-450v20q0 13-8.5 21.5T680-400Zm0 320q13 0 21.5 8.5T710-50v20q0 13-8.5 21.5T680 0q-13 0-21.5-8.5T650-30v-20q0-13 8.5-21.5T680-80Zm114-274q-9-9-9-21.5t9-21.5l14-14q9-9 21-8.5t21 9.5q9 9 9 21t-9 21l-14 14q-9 9-21 9t-21-9ZM567-127q9 9 9 21t-9 21l-15 15q-9 9-21 9t-21-9q-9-9-9-21t9-21l15-15q9-9 21-9t21 9Zm273-113q0-13 8.5-21.5T870-270h20q13 0 21.5 8.5T920-240q0 13-8.5 21.5T890-210h-20q-13 0-21.5-8.5T840-240Zm-320 0q0 13-8.5 21.5T490-210h-20q-13 0-21.5-8.5T440-240q0-13 8.5-21.5T470-270h20q13 0 21.5 8.5T520-240Zm274 113q9-9 21-8.5t21 8.5l15 14q9 9 9 21t-9 21q-9 9-21.5 9T808-71l-14-14q-8-9-8.5-21t8.5-21ZM567-354q-9 9-21.5 9t-21.5-9l-14-14q-8-9-8.5-21t8.5-21q9-9 21-8.5t21 8.5l15 14q9 9 9 21t-9 21ZM200-80q-33 0-56.5-23.5T120-160v-560q0-33 23.5-56.5T200-800h40v-40q0-17 11.5-28.5T280-880q17 0 28.5 11.5T320-840v40h320v-40q0-17 11.5-28.5T680-880q17 0 28.5 11.5T720-840v40h40q33 0 56.5 23.5T840-720v120q0 17-11.5 28.5T800-560H200v400h120q17 0 28.5 11.5T360-120q0 17-11.5 28.5T320-80H200Zm0-560h560v-80H200v80Zm0 0v-80 80Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

@ -40,7 +40,7 @@ body.isDarkTheme {
/* Component backgrounds */
--card-bg: var(--arc-bg-medium);
--sidebar-bg: var(--arc-sidebar);
--sidenav-bg: var(--arc-sidebar);
--selected-task-bg-color: var(--arc-bg-medium);
--banner-bg: var(--arc-bg-medium);

View file

@ -30,7 +30,7 @@ body.isDarkTheme {
/* Component backgrounds */
--card-bg: #141414;
--sidebar-bg: #0f0f0f;
--sidenav-bg: #0f0f0f;
--selected-task-bg-color: #1f1f1f;
--banner-bg: #1a1a1a;

View file

@ -47,7 +47,7 @@ body.isDarkTheme {
/* Component backgrounds */
--card-bg: #2c2e3a;
--sidebar-bg: #21222c;
--sidenav-bg: #21222c;
--selected-task-bg-color: #343746;
--banner-bg: #343746;

View file

@ -63,7 +63,7 @@ body:not(.isDarkTheme) {
/* Component backgrounds */
--card-bg: #fffbf0;
--sidebar-bg: var(--everforest-bg1);
--sidenav-bg: var(--everforest-bg1);
--selected-task-bg-color: var(--everforest-bg2);
--banner-bg: var(--everforest-bg2);
@ -272,7 +272,7 @@ body.isDarkTheme {
/* Component backgrounds */
--card-bg: var(--everforest-bg1);
--sidebar-bg: var(--everforest-bg-dim);
--sidenav-bg: var(--everforest-bg-dim);
--selected-task-bg-color: var(--everforest-bg2);
--banner-bg: var(--everforest-bg2);

View file

@ -41,7 +41,7 @@ body.isDarkTheme {
/* Glass component backgrounds - darker */
--card-bg: rgba(255, 255, 255, 0.05);
--sidebar-bg: rgba(255, 255, 255, 0.02);
--sidenav-bg: rgba(255, 255, 255, 0.02);
--selected-task-bg-color: rgba(255, 255, 255, 0.08);
--banner-bg: rgba(255, 255, 255, 0.06);

View file

@ -54,7 +54,7 @@ body.isDarkTheme {
/* Component backgrounds */
--card-bg: var(--nord2);
--sidebar-bg: var(--nord0);
--sidenav-bg: var(--nord0);
--selected-task-bg-color: var(--nord3);
--banner-bg: var(--nord3);

View file

@ -59,7 +59,7 @@ body:not(.isDarkTheme) {
/* Component backgrounds */
--card-bg: #ffffff;
--sidebar-bg: var(--nord5);
--sidenav-bg: var(--nord5);
--selected-task-bg-color: #f2f4f8;
--banner-bg: #f2f4f8;

View file

@ -82,7 +82,7 @@ body.isDarkTheme {
/* Component backgrounds */
--card-bg: #2d2d2d;
--sidebar-bg: #0f0f0f;
--sidenav-bg: #0f0f0f;
}
body.isDarkTheme mat-sidenav-content {

View file

@ -332,8 +332,15 @@ body {
--card-bg: #ffffff;
// Navigation & Panel backgrounds
--sidebar-bg: var(--bg);
//--sidebar-border-color: var(--extra-border-color);
--sidenav-bg: transparent;
--sidenav-bg-mobile: var(--bg);
--sidenav-active: rgba(255, 255, 255, 1.25);
--sidenav-active-text: var(--palette-primary-800);
--sidebar-border: var(--divider-color);
--sidebar-hover: var(--palette-background-hover);
--sidebar-text: var(--text-color);
--sidebar-text-secondary: var(--text-color-muted);
--overlay-bg: var(--c-backdrop, rgba(0, 0, 0, 0.6));
--right-panel-bg: #fff;
// Text colors
@ -375,9 +382,9 @@ body {
--task-selected-shadow: var(--whiteframe-shadow-3dp);
// Scrollbar colors
--scrollbar-thumb: #888;
--scrollbar-thumb-hover: #555;
--scrollbar-track: #f1f1f1;
--scrollbar-thumb: rgba(136, 136, 136, 0.5);
--scrollbar-thumb-hover: rgba(85, 85, 85, 0.7);
--scrollbar-track: rgba(241, 241, 241, 0.3);
// Chip colors
--chip-outline-color: rgba(125, 125, 125, 0.4);
@ -448,14 +455,21 @@ body.isDarkTheme {
--card-bg: var(--dark2);
// Navigation & Panel backgrounds
--sidebar-bg: var(--dark1);
--sidebar-border-color: var(--dark12);
--sidenav-bg: transparent;
--sidenav-bg-mobile: var(--bg);
--sidenav-active: rgba(255, 255, 255, 0.075);
--sidenav-active-text: var(--c-primary);
--sidebar-border: var(--divider-color);
--sidebar-hover: var(--palette-background-hover);
--sidebar-text: var(--text-color);
--sidebar-text-secondary: var(--text-color-muted);
--overlay-bg: var(--c-backdrop, rgba(0, 0, 0, 0.8));
--right-panel-bg: var(--dark5);
// Text colors
--text-color: rgb(230, 230, 230);
--text-color-less-intense: rgba(235, 235, 235, 0.9);
--text-color-muted: rgba(235, 235, 235, 0.6);
--text-color-muted: rgba(235, 235, 235, 0.75);
--text-color-more-intense: rgb(245, 245, 245);
--text-color-most-intense: rgb(255, 255, 255);
@ -493,9 +507,9 @@ body.isDarkTheme {
--task-selected-shadow: var(--whiteframe-shadow-4dp);
// Scrollbar colors
--scrollbar-thumb: #333;
--scrollbar-thumb-hover: #444;
--scrollbar-track: #222;
--scrollbar-thumb: rgba(51, 51, 51, 0.6);
--scrollbar-thumb-hover: rgba(68, 68, 68, 0.8);
--scrollbar-track: rgba(34, 34, 34, 0.4);
// Grid colors
--grid-color: var(--c-light-10);

View file

@ -124,3 +124,9 @@ body.isDarkTheme .show-light-only {
.block {
display: block;
}
.milk-glass {
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%); // Safari support
}