feat: add account switcher menu (#941)

This commit is contained in:
Aman Harwara
2022-03-22 21:59:06 +05:30
committed by GitHub
parent 57469d6b2d
commit a764987b20
11 changed files with 273 additions and 225 deletions

View File

@@ -10,10 +10,13 @@ import { AccountMenuPane } from '.';
import { FunctionComponent } from 'preact';
import { Menu } from '../Menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from '../Menu/MenuItem';
import { WorkspaceSwitcherOption } from './WorkspaceSwitcher/WorkspaceSwitcherOption';
import { ApplicationGroup } from '@/ui_models/application_group';
type Props = {
appState: AppState;
application: WebApplication;
mainApplicationGroup: ApplicationGroup;
setMenuPane: (pane: AccountMenuPane) => void;
closeMenu: () => void;
};
@@ -21,7 +24,7 @@ type Props = {
const iconClassName = 'color-neutral mr-2';
export const GeneralAccountMenu: FunctionComponent<Props> = observer(
({ application, appState, setMenuPane, closeMenu }) => {
({ application, appState, setMenuPane, closeMenu, mainApplicationGroup }) => {
const [isSyncingInProgress, setIsSyncingInProgress] = useState(false);
const [lastSyncDate, setLastSyncDate] = useState(
formatLastSyncDate(application.sync.getLastSyncDate() as Date)
@@ -56,7 +59,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
return (
<>
<div className="flex items-center justify-between px-3 mt-1 mb-3">
<div className="flex items-center justify-between px-3 mt-1 mb-1">
<div className="sn-account-menu-headline">Account</div>
<div className="flex cursor-pointer" onClick={closeMenu}>
<Icon type="close" className="color-neutral" />
@@ -69,7 +72,7 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
<div className="my-0.5 font-bold wrap">{user.email}</div>
<span className="color-neutral">{application.getHost()}</span>
</div>
<div className="flex items-start justify-between px-3 mb-2">
<div className="flex items-start justify-between px-3 mb-3">
{isSyncingInProgress ? (
<div className="flex items-center color-info font-semibold">
<div className="sk-spinner w-5 h-5 mr-2 spinner-info"></div>
@@ -106,12 +109,17 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
</div>
</>
)}
<div className="h-1px my-2 bg-border"></div>
<Menu
isOpen={appState.accountMenu.show}
a11yLabel="General account menu"
closeMenu={closeMenu}
>
<MenuItemSeparator />
<WorkspaceSwitcherOption
mainApplicationGroup={mainApplicationGroup}
appState={appState}
/>
<MenuItemSeparator />
{user ? (
<MenuItem
type={MenuItemType.IconButton}

View File

@@ -0,0 +1,82 @@
import { Icon } from '@/components/Icon';
import { MenuItem, MenuItemType } from '@/components/Menu/MenuItem';
import { KeyboardKey } from '@/services/ioService';
import { ApplicationDescriptor } from '@standardnotes/snjs/dist/@types';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
type Props = {
descriptor: ApplicationDescriptor;
onClick: () => void;
onDelete: () => void;
renameDescriptor: (label: string) => void;
};
export const WorkspaceMenuItem: FunctionComponent<Props> = ({
descriptor,
onClick,
onDelete,
renameDescriptor,
}) => {
const [isRenaming, setIsRenaming] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isRenaming) {
inputRef.current?.focus();
}
}, [isRenaming]);
const handleInputKeyDown = (event: KeyboardEvent) => {
if (event.key === KeyboardKey.Enter) {
inputRef.current?.blur();
}
};
const handleInputBlur = (event: FocusEvent) => {
const name = (event.target as HTMLInputElement).value;
renameDescriptor(name);
setIsRenaming(false);
};
return (
<MenuItem
type={MenuItemType.RadioButton}
className="sn-dropdown-item py-2 focus:bg-info-backdrop focus:shadow-none"
onClick={onClick}
checked={descriptor.primary}
>
<div className="flex items-center justify-between w-full">
{isRenaming ? (
<input
ref={inputRef}
type="text"
value={descriptor.label}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
/>
) : (
<div>{descriptor.label}</div>
)}
{descriptor.primary && (
<div>
<button
className="w-5 h-5 p-0 mr-3 border-0 bg-transparent hover:bg-contrast cursor-pointer"
onClick={() => {
setIsRenaming((isRenaming) => !isRenaming);
}}
>
<Icon type="pencil" className="sn-icon--mid color-neutral" />
</button>
<button
className="w-5 h-5 p-0 border-0 bg-transparent hover:bg-contrast cursor-pointer"
onClick={onDelete}
>
<Icon type="trash" className="sn-icon--mid color-danger" />
</button>
</div>
)}
</div>
</MenuItem>
);
};

View File

@@ -0,0 +1,69 @@
import { ApplicationGroup } from '@/ui_models/application_group';
import { AppState } from '@/ui_models/app_state';
import { ApplicationDescriptor } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { Icon } from '../../Icon';
import { Menu } from '../../Menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from '../../Menu/MenuItem';
import { WorkspaceMenuItem } from './WorkspaceMenuItem';
type Props = {
mainApplicationGroup: ApplicationGroup;
appState: AppState;
isOpen: boolean;
};
export const WorkspaceSwitcherMenu: FunctionComponent<Props> = observer(
({ mainApplicationGroup, appState, isOpen }) => {
const [applicationDescriptors, setApplicationDescriptors] = useState<
ApplicationDescriptor[]
>([]);
useEffect(() => {
const removeAppGroupObserver =
mainApplicationGroup.addApplicationChangeObserver(() => {
const applicationDescriptors = mainApplicationGroup.getDescriptors();
setApplicationDescriptors(applicationDescriptors);
});
return () => {
removeAppGroupObserver();
};
}, [mainApplicationGroup]);
return (
<Menu
a11yLabel="Workspace switcher menu"
className="px-0 focus:shadow-none"
isOpen={isOpen}
>
{applicationDescriptors.map((descriptor) => (
<WorkspaceMenuItem
descriptor={descriptor}
onDelete={() => {
appState.accountMenu.setSigningOut(true);
}}
onClick={() => {
mainApplicationGroup.loadApplicationForDescriptor(descriptor);
}}
renameDescriptor={(label: string) =>
mainApplicationGroup.renameDescriptor(descriptor, label)
}
/>
))}
<MenuItemSeparator />
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
mainApplicationGroup.addNewApplication();
}}
>
<Icon type="user-add" className="color-neutral mr-2" />
Add another workspace
</MenuItem>
</Menu>
);
}
);

View File

@@ -0,0 +1,83 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
import { ApplicationGroup } from '@/ui_models/application_group';
import { AppState } from '@/ui_models/app_state';
import {
calculateSubmenuStyle,
SubmenuStyle,
} from '@/utils/calculateSubmenuStyle';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Icon } from '../../Icon';
import { WorkspaceSwitcherMenu } from './WorkspaceSwitcherMenu';
type Props = {
mainApplicationGroup: ApplicationGroup;
appState: AppState;
};
export const WorkspaceSwitcherOption: FunctionComponent<Props> = observer(
({ mainApplicationGroup, appState }) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>();
const toggleMenu = () => {
if (!isOpen) {
const menuPosition = calculateSubmenuStyle(buttonRef.current);
if (menuPosition) {
setMenuStyle(menuPosition);
}
}
setIsOpen(!isOpen);
};
useEffect(() => {
if (isOpen) {
setTimeout(() => {
const newMenuPosition = calculateSubmenuStyle(
buttonRef.current,
menuRef.current
);
if (newMenuPosition) {
setMenuStyle(newMenuPosition);
}
});
}
}, [isOpen]);
return (
<>
<button
ref={buttonRef}
className="sn-dropdown-item justify-between focus:bg-info-backdrop focus:shadow-none"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
role="menuitem"
onClick={toggleMenu}
>
<div className="flex items-center">
<Icon type="user-switch" className="color-neutral mr-2" />
Switch workspace
</div>
<Icon type="chevron-right" className="color-neutral" />
</button>
{isOpen && (
<div
ref={menuRef}
className="sn-dropdown max-h-120 min-w-68 py-2 fixed overflow-y-auto"
style={menuStyle}
>
<WorkspaceSwitcherMenu
mainApplicationGroup={mainApplicationGroup}
appState={appState}
isOpen={isOpen}
/>
</div>
)}
</>
);
}
);

View File

@@ -9,6 +9,7 @@ import { SignInPane } from './SignIn';
import { CreateAccount } from './CreateAccount';
import { ConfirmPassword } from './ConfirmPassword';
import { JSXInternal } from 'preact/src/jsx';
import { ApplicationGroup } from '@/ui_models/application_group';
export enum AccountMenuPane {
GeneralMenu,
@@ -21,18 +22,27 @@ type Props = {
appState: AppState;
application: WebApplication;
onClickOutside: () => void;
mainApplicationGroup: ApplicationGroup;
};
type PaneSelectorProps = {
appState: AppState;
application: WebApplication;
mainApplicationGroup: ApplicationGroup;
menuPane: AccountMenuPane;
setMenuPane: (pane: AccountMenuPane) => void;
closeMenu: () => void;
};
const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
({ application, appState, menuPane, setMenuPane, closeMenu }) => {
({
application,
appState,
menuPane,
setMenuPane,
closeMenu,
mainApplicationGroup,
}) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -42,6 +52,7 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
<GeneralAccountMenu
appState={appState}
application={application}
mainApplicationGroup={mainApplicationGroup}
setMenuPane={setMenuPane}
closeMenu={closeMenu}
/>
@@ -81,7 +92,7 @@ const MenuPaneSelector: FunctionComponent<PaneSelectorProps> = observer(
);
export const AccountMenu: FunctionComponent<Props> = observer(
({ application, appState, onClickOutside }) => {
({ application, appState, onClickOutside, mainApplicationGroup }) => {
const {
currentPane,
setCurrentPane,
@@ -123,6 +134,7 @@ export const AccountMenu: FunctionComponent<Props> = observer(
<MenuPaneSelector
appState={appState}
application={application}
mainApplicationGroup={mainApplicationGroup}
menuPane={currentPane}
setMenuPane={setCurrentPane}
closeMenu={closeAccountMenu}