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}

View File

@@ -1,169 +0,0 @@
import { ApplicationGroup } from '@/ui_models/application_group';
import { WebApplication } from '@/ui_models/application';
import { ApplicationDescriptor } from '@standardnotes/snjs';
import { PureComponent } from '@/components/Abstract/PureComponent';
import { JSX } from 'preact';
type Props = {
application: WebApplication;
mainApplicationGroup: ApplicationGroup;
};
type State = {
descriptors: ApplicationDescriptor[];
editingDescriptor?: ApplicationDescriptor;
};
export class AccountSwitcher extends PureComponent<Props, State> {
private removeAppGroupObserver: any;
activeApplication!: WebApplication;
constructor(props: Props) {
super(props, props.application);
this.removeAppGroupObserver =
props.mainApplicationGroup.addApplicationChangeObserver(() => {
this.activeApplication = props.mainApplicationGroup
.primaryApplication as WebApplication;
this.reloadApplications();
});
}
reloadApplications() {
this.setState({
descriptors: this.props.mainApplicationGroup.getDescriptors(),
});
}
addNewApplication = () => {
this.dismiss();
this.props.mainApplicationGroup.addNewApplication();
};
selectDescriptor = (descriptor: ApplicationDescriptor) => {
this.dismiss();
this.props.mainApplicationGroup.loadApplicationForDescriptor(descriptor);
};
inputForDescriptor(descriptor: ApplicationDescriptor) {
return document.getElementById(`input-${descriptor.identifier}`);
}
renameDescriptor = (event: Event, descriptor: ApplicationDescriptor) => {
event.stopPropagation();
this.setState({ editingDescriptor: descriptor });
setTimeout(() => {
this.inputForDescriptor(descriptor)?.focus();
});
};
submitRename = () => {
this.props.mainApplicationGroup.renameDescriptor(
this.state.editingDescriptor!,
this.state.editingDescriptor!.label
);
this.setState({ editingDescriptor: undefined });
};
deinit() {
super.deinit();
this.removeAppGroupObserver();
this.removeAppGroupObserver = undefined;
}
onDescriptorInputChange = (
descriptor: ApplicationDescriptor,
{ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>
) => {
descriptor.label = currentTarget.value;
};
dismiss = () => {
this.dismissModal();
};
render() {
return (
<div className="sk-modal">
<div onClick={this.dismiss} className="sk-modal-background" />
<div id="account-switcher" className="sk-modal-content">
<div className="sn-component">
<div id="menu-panel" className="sk-menu-panel">
<div className="sk-menu-panel-header">
<div className="sk-menu-panel-column">
<div className="sk-menu-panel-header-title">
Account Switcher
</div>
</div>
<div className="sk-menu-panel-column">
<a onClick={this.addNewApplication} className="sk-label info">
Add Account
</a>
</div>
</div>
{this.state.descriptors.map((descriptor) => {
return (
<div
key={descriptor.identifier}
onClick={() => this.selectDescriptor(descriptor)}
className="sk-menu-panel-row"
>
<div className="sk-menu-panel-column stretch">
<div className="left">
{descriptor.identifier ==
this.activeApplication.identifier && (
<div className="sk-menu-panel-column">
<div className="sk-circle small success" />
</div>
)}
<div className="sk-menu-panel-column stretch">
<input
value={descriptor.label}
disabled={
descriptor !== this.state.editingDescriptor
}
onChange={(event) =>
this.onDescriptorInputChange(descriptor, event)
}
onKeyUp={(event) =>
event.keyCode == 13 && this.submitRename()
}
id={`input-${descriptor.identifier}`}
spellcheck={false}
className="sk-label clickable"
/>
{descriptor.identifier ==
this.activeApplication.identifier && (
<div className="sk-sublabel">
Current Application
</div>
)}
</div>
{descriptor.identifier ==
this.activeApplication.identifier && (
<div className="sk-menu-panel-column">
<button
onClick={(event) =>
this.renameDescriptor(event, descriptor)
}
className="sn-button success"
>
Rename
</button>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
}

View File

@@ -23,7 +23,6 @@ import { Icon } from './Icon';
import { QuickSettingsMenu } from './QuickSettingsMenu/QuickSettingsMenu';
import { SyncResolutionMenu } from './SyncResolutionMenu';
import { Fragment, render } from 'preact';
import { AccountSwitcher } from './AccountSwitcher';
/**
* Disable before production release.
@@ -43,7 +42,6 @@ type State = {
dataUpgradeAvailable: boolean;
hasPasscode: boolean;
descriptors: ApplicationDescriptor[];
hasAccountSwitcher: boolean;
showBetaWarning: boolean;
showSyncResolution: boolean;
newUpdateAvailable: boolean;
@@ -70,7 +68,6 @@ export class Footer extends PureComponent<Props, State> {
dataUpgradeAvailable: false,
hasPasscode: false,
descriptors: props.applicationGroup.getDescriptors(),
hasAccountSwitcher: false,
showBetaWarning: false,
showSyncResolution: false,
newUpdateAvailable: false,
@@ -100,7 +97,6 @@ export class Footer extends PureComponent<Props, State> {
arbitraryStatusMessage: message,
});
});
this.loadAccountSwitcherState();
this.autorun(() => {
const showBetaWarning = this.appState.showBetaWarning;
this.setState({
@@ -111,18 +107,6 @@ export class Footer extends PureComponent<Props, State> {
});
}
loadAccountSwitcherState() {
const stringValue = localStorage.getItem(ACCOUNT_SWITCHER_FEATURE_KEY);
if (!stringValue && ACCOUNT_SWITCHER_ENABLED) {
/** Enable permanently for this user so they don't lose the feature after its disabled */
localStorage.setItem(ACCOUNT_SWITCHER_FEATURE_KEY, JSON.stringify(true));
}
const hasAccountSwitcher = stringValue
? JSON.parse(stringValue)
: ACCOUNT_SWITCHER_ENABLED;
this.setState({ hasAccountSwitcher });
}
reloadUpgradeStatus() {
this.application.checkForSecurityUpdate().then((available) => {
this.setState({
@@ -333,16 +317,6 @@ export class Footer extends PureComponent<Props, State> {
}
};
accountSwitcherClickHandler = () => {
render(
<AccountSwitcher
application={this.application}
mainApplicationGroup={this.props.applicationGroup}
/>,
document.body.appendChild(document.createElement('div'))
);
};
accountMenuClickHandler = () => {
this.appState.quickSettingsMenu.closeQuickSettingsMenu();
this.appState.accountMenu.toggleShow();
@@ -429,6 +403,7 @@ export class Footer extends PureComponent<Props, State> {
onClickOutside={this.clickOutsideAccountMenu}
appState={this.appState}
application={this.application}
mainApplicationGroup={this.props.applicationGroup}
/>
)}
</div>
@@ -522,24 +497,6 @@ export class Footer extends PureComponent<Props, State> {
<div className="sk-label">Offline</div>
</div>
)}
{this.state.hasAccountSwitcher && (
<Fragment>
<div className="sk-app-bar-item border" />
<div
onClick={this.accountSwitcherClickHandler}
className="sk-app-bar-item"
>
<div
className={
(this.state.hasPasscode ? 'alone' : '') +
' flex items-center'
}
>
<Icon type="user-switch" />
</div>
</div>
</Fragment>
)}
{this.state.hasPasscode && (
<Fragment>
<div className="sk-app-bar-item border" />

View File

@@ -16,9 +16,9 @@ import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ClearCircleFilledIcon,
CloseIcon,
CloudOffIcon,
ClearCircleFilledIcon,
CodeIcon,
CopyIcon,
DashboardIcon,
@@ -83,6 +83,7 @@ import {
TuneIcon,
UnarchiveIcon,
UnpinIcon,
UserAddIcon,
UserIcon,
UserSwitch,
WarningIcon,
@@ -99,8 +100,8 @@ export const ICONS = {
'check-circle': CheckCircleIcon,
'chevron-down': ChevronDownIcon,
'chevron-right': ChevronRightIcon,
'cloud-off': CloudOffIcon,
'clear-circle-filled': ClearCircleFilledIcon,
'cloud-off': CloudOffIcon,
'eye-off': EyeOffIcon,
'file-doc': FileDocIcon,
'file-image': FileImageIcon,
@@ -127,6 +128,7 @@ export const ICONS = {
'rich-text': RichTextIcon,
'trash-filled': TrashFilledIcon,
'trash-sweep': TrashSweepIcon,
'user-add': UserAddIcon,
'user-switch': UserSwitch,
accessibility: AccessibilityIcon,
add: AddIcon,

View File

@@ -73,8 +73,12 @@ export const Menu: FunctionComponent<MenuProps> = ({
child: ComponentChild,
index: number,
array: ComponentChild[]
) => {
if (!child) return;
): ComponentChild => {
if (!child || (Array.isArray(child) && child.length < 1)) return;
if (Array.isArray(child)) {
return child.map(mapMenuItems);
}
const _child = child as VNode<unknown>;
const isFirstMenuItem =

View File

@@ -79,7 +79,7 @@ export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
<div
className={`pseudo-radio-btn ${
checked ? 'pseudo-radio-btn--checked' : ''
} mr-2`}
} mr-2 flex-shrink-0`}
></div>
) : null}
{children}

View File

@@ -14,7 +14,7 @@ export type SubmenuStyle = {
export const calculateSubmenuStyle = (
button: HTMLButtonElement | null,
menu?: HTMLDivElement | null
menu?: HTMLDivElement | HTMLMenuElement | null
): SubmenuStyle | undefined => {
const defaultFontSize = window.getComputedStyle(
document.documentElement