feat: multiple selected notes panel

This commit is contained in:
Baptiste Grob
2021-04-08 11:30:56 +02:00
parent 0f53361689
commit abfc588368
36 changed files with 542 additions and 128 deletions

View File

@@ -0,0 +1,192 @@
import { AppState } from '@/ui_models/app_state';
import VisuallyHidden from '@reach/visually-hidden';
import { toDirective, useCloseOnBlur } from './utils';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import MoreIcon from '../../icons/ic-more.svg';
import PencilOffIcon from '../../icons/ic-pencil-off.svg';
import RichTextIcon from '../../icons/ic-text-rich.svg';
import TrashIcon from '../../icons/ic-trash.svg';
import PinIcon from '../../icons/ic-pin.svg';
import UnpinIcon from '../../icons/ic-pin-off.svg';
import ArchiveIcon from '../../icons/ic-archive.svg';
import UnarchiveIcon from '../../icons/ic-unarchive.svg';
import NotesIcon from '../../icons/il-notes.svg';
import { useRef, useState } from 'preact/hooks';
import { Switch } from './Switch';
import { observer } from 'mobx-react-lite';
import { SNApplication } from '@standardnotes/snjs';
type Props = {
application: SNApplication;
appState: AppState;
};
const MultipleSelectedNotes = observer(({ appState }: Props) => {
const count = appState.notes.selectedNotesCount;
const [open, setOpen] = useState(false);
const [optionsPanelPosition, setOptionsPanelPosition] = useState({
top: 0,
right: 0,
});
const buttonRef = useRef<HTMLButtonElement>();
const panelRef = useRef<HTMLDivElement>();
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen);
const notes = Object.values(appState.notes.selectedNotes);
const hidePreviews = !notes.some((note) => !note.hidePreview);
const locked = !notes.some((note) => !note.locked);
const archived = !notes.some((note) => !note.archived);
const trashed = !notes.some((note) => !note.trashed);
const pinned = !notes.some((note) => !note.pinned);
const iconClass = 'fill-current color-neutral mr-2.5';
const buttonClass =
'flex items-center border-0 capitalize focus:inner-ring-info ' +
'cursor-pointer hover:bg-contrast color-text bg-transparent h-10 px-3 ' +
'text-left';
return (
<div className="flex flex-col h-full items-center">
<div className="flex items-center justify-between p-4 w-full">
<h1 className="text-3xl m-0">{count} selected notes</h1>
<Disclosure
open={open}
onChange={() => {
const rect = buttonRef.current.getBoundingClientRect();
setOptionsPanelPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
});
setOpen((prevOpen) => !prevOpen);
}}
>
<DisclosureButton
onKeyUp={(event) => {
if (event.key === 'Escape') {
setOpen(false);
}
}}
onBlur={closeOnBlur}
ref={buttonRef}
className={
'bg-transparent border-solid border-1 border-gray-300 ' +
'cursor-pointer w-32px h-32px rounded-full p-0 ' +
'flex justify-center items-center'
}
>
<VisuallyHidden>Actions</VisuallyHidden>
<MoreIcon className="fill-current block" />
</DisclosureButton>
<DisclosurePanel
onKeyUp={(event) => {
if (event.key === 'Escape') {
setOpen(false);
buttonRef.current.focus();
}
}}
ref={panelRef}
style={{
...optionsPanelPosition,
}}
className="sn-dropdown sn-dropdown-anchor-right flex flex-col py-2 select-none"
>
<Switch
onBlur={closeOnBlur}
className="h-10"
checked={locked}
onChange={() => {
appState.notes.setLockSelectedNotes(!locked);
}}
>
<span className="capitalize flex items-center">
<PencilOffIcon className={iconClass} />
Prevent editing
</span>
</Switch>
<Switch
onBlur={closeOnBlur}
className="h-10"
checked={!hidePreviews}
onChange={() => {
appState.notes.setHideSelectedNotePreviews(!hidePreviews);
}}
>
<span className="capitalize flex items-center">
<RichTextIcon className={iconClass} />
Show Preview
</span>
</Switch>
<div className="h-1px my-2.5 bg-secondary-contrast"></div>
<button
onBlur={closeOnBlur}
className={buttonClass}
onClick={() => {
appState.notes.setPinSelectedNotes(!pinned);
}}
>
{pinned ? (
<>
<UnpinIcon className={iconClass} />
Unpin notes
</>
) : (
<>
<PinIcon className={iconClass} />
Pin notes
</>
)}
</button>
<button
onBlur={closeOnBlur}
className={buttonClass}
onClick={() => {
appState.notes.setArchiveSelectedNotes(!archived);
}}
>
{archived ? (
<>
<UnarchiveIcon className={iconClass} />
Unarchive
</>
) : (
<>
<ArchiveIcon className={iconClass} />
Archive
</>
)}
</button>
<button
onBlur={closeOnBlur}
className={buttonClass}
onClick={async () => {
setLockCloseOnBlur(true);
await appState.notes.setTrashSelectedNotes(!trashed);
setLockCloseOnBlur(false);
}}
>
<TrashIcon className={iconClass} />
{trashed ? 'Restore' : 'Move to trash'}
</button>
</DisclosurePanel>
</Disclosure>
</div>
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
<NotesIcon className="block" />
<h2 className="text-2xl m-0 text-center mt-4">
{count} selected notes
</h2>
<p className="text-lg mt-2 text-center">
Actions will be performed on all selected notes.
</p>
</div>
</div>
);
});
export const MultipleSelectedNotesDirective = toDirective<Props>(
MultipleSelectedNotes
);

View File

@@ -1,13 +1,12 @@
import { toDirective, useAutorunValue } from './utils';
import Close from '../../icons/ic_close.svg';
import { toDirective } from './utils';
import Close from '../../icons/ic-close.svg';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
type Props = { appState: AppState };
function NoAccountWarning({ appState }: Props) {
const canShow = useAutorunValue(() => appState.noAccountWarning.show, [
appState,
]);
const NoAccountWarning = observer(({ appState }: Props) => {
const canShow = appState.noAccountWarning.show;
if (!canShow) {
return null;
}
@@ -39,6 +38,6 @@ function NoAccountWarning({ appState }: Props) {
</button>
</div>
);
}
});
export const NoAccountWarningDirective = toDirective<Props>(NoAccountWarning);

View File

@@ -1,5 +1,5 @@
import { AppState } from '@/ui_models/app_state';
import { toDirective, useAutorunValue } from './utils';
import { toDirective, useCloseOnBlur } from './utils';
import { useRef, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import VisuallyHidden from '@reach/visually-hidden';
@@ -10,54 +10,35 @@ import {
} from '@reach/disclosure';
import { FocusEvent } from 'react';
import { Switch } from './Switch';
import TuneIcon from '../../icons/ic_tune.svg';
import TuneIcon from '../../icons/ic-tune.svg';
import { observer } from 'mobx-react-lite';
type Props = {
appState: AppState;
application: WebApplication;
};
function SearchOptions({ appState }: Props) {
const SearchOptions = observer(({ appState }: Props) => {
const { searchOptions } = appState;
const {
includeProtectedContents,
includeArchived,
includeTrashed,
} = useAutorunValue(
() => ({
includeProtectedContents: searchOptions.includeProtectedContents,
includeArchived: searchOptions.includeArchived,
includeTrashed: searchOptions.includeTrashed,
}),
[searchOptions]
);
const [
togglingIncludeProtectedContents,
setTogglingIncludeProtectedContents,
] = useState(false);
async function toggleIncludeProtectedContents() {
setTogglingIncludeProtectedContents(true);
try {
await searchOptions.toggleIncludeProtectedContents();
} finally {
setTogglingIncludeProtectedContents(false);
}
}
} = searchOptions;
const [open, setOpen] = useState(false);
const [optionsPanelTop, setOptionsPanelTop] = useState(0);
const buttonRef = useRef<HTMLButtonElement>();
const panelRef = useRef<HTMLDivElement>();
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen);
function closeOnBlur(event: FocusEvent<HTMLElement>) {
if (
!togglingIncludeProtectedContents &&
!panelRef.current.contains(event.relatedTarget as Node)
) {
setOpen(false);
async function toggleIncludeProtectedContents() {
setLockCloseOnBlur(true);
try {
await searchOptions.toggleIncludeProtectedContents();
} finally {
setLockCloseOnBlur(false);
}
}
@@ -86,6 +67,7 @@ function SearchOptions({ appState }: Props) {
className="sn-dropdown sn-dropdown-anchor-right grid gap-2 py-2"
>
<Switch
className="h-10"
checked={includeProtectedContents}
onChange={toggleIncludeProtectedContents}
onBlur={closeOnBlur}
@@ -93,6 +75,7 @@ function SearchOptions({ appState }: Props) {
<p className="capitalize">Include protected contents</p>
</Switch>
<Switch
className="h-10"
checked={includeArchived}
onChange={searchOptions.toggleIncludeArchived}
onBlur={closeOnBlur}
@@ -100,6 +83,7 @@ function SearchOptions({ appState }: Props) {
<p className="capitalize">Include archived notes</p>
</Switch>
<Switch
className="h-10"
checked={includeTrashed}
onChange={searchOptions.toggleIncludeTrashed}
onBlur={closeOnBlur}
@@ -109,6 +93,6 @@ function SearchOptions({ appState }: Props) {
</DisclosurePanel>
</Disclosure>
);
}
});
export const SearchOptionsDirective = toDirective<Props>(SearchOptions);

View File

@@ -15,8 +15,9 @@ import {
AlertDialogDescription,
AlertDialogLabel,
} from '@reach/alert-dialog';
import { toDirective, useAutorunValue } from './utils';
import { toDirective } from './utils';
import { WebApplication } from '@/ui_models/application';
import { observer } from 'mobx-react-lite';
type Session = RemoteSession & {
revoking?: true;
@@ -242,16 +243,12 @@ const SessionsModal: FunctionComponent<{
const Sessions: FunctionComponent<{
appState: AppState;
application: WebApplication;
}> = ({ appState, application }) => {
const showModal = useAutorunValue(() => appState.isSessionsModalVisible, [
appState,
]);
if (showModal) {
}> = observer(({ appState, application }) => {
if (appState.isSessionsModalVisible) {
return <SessionsModal application={application} appState={appState} />;
} else {
return null;
}
};
});
export const SessionsModalDirective = toDirective(Sessions);

View File

@@ -11,6 +11,7 @@ import '@reach/checkbox/styles.css';
export type SwitchProps = HTMLProps<HTMLInputElement> & {
checked?: boolean;
onChange: (checked: boolean) => void;
className?: string;
children: ComponentChildren;
};
@@ -19,8 +20,9 @@ export const Switch: FunctionalComponent<SwitchProps> = (
) => {
const [checkedState, setChecked] = useState(props.checked || false);
const checked = props.checked ?? checkedState;
const className = props.className ?? '';
return (
<label className="sn-component flex justify-between items-center cursor-pointer hover:bg-contrast py-2 px-3">
<label className={`sn-component flex justify-between items-center cursor-pointer hover:bg-contrast px-3 ${className}`}>
{props.children}
<CustomCheckboxContainer
checked={checked}
@@ -33,6 +35,7 @@ export const Switch: FunctionalComponent<SwitchProps> = (
<CustomCheckboxInput
{...({
...props,
className: undefined,
children: undefined,
} as CustomCheckboxInputProps)}
/>

View File

@@ -1,16 +1,31 @@
import { autorun } from 'mobx';
import { FunctionComponent, h, render } from 'preact';
import { Inputs, useEffect, useState } from 'preact/hooks';
import { StateUpdater, useCallback, useState } from 'preact/hooks';
import { FocusEvent, EventHandler, FocusEventHandler } from 'react';
export function useAutorunValue<T>(query: () => T, inputs: Inputs): T {
const [value, setValue] = useState(query);
useEffect(() => {
return autorun(() => {
setValue(query());
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, inputs);
return value;
/**
* @returns a callback that will close a dropdown if none of its children has
* focus. Must be set as the onBlur callback of children that need to be
* monitored.
*/
export function useCloseOnBlur(
container: { current: HTMLDivElement },
setOpen: (open: boolean) => void
): [(event: { relatedTarget: EventTarget | null }) => void, StateUpdater<boolean>] {
const [locked, setLocked] = useState(false);
return [
useCallback(
function onBlur(event: { relatedTarget: EventTarget | null }) {
if (
!locked &&
!container.current.contains(event.relatedTarget as Node)
) {
setOpen(false);
}
},
[container, setOpen, locked]
),
setLocked,
];
}
export function toDirective<Props>(