feat: Update notes list options menu to new design (#687)

feat: Implement initial Menu component functionality.
This commit is contained in:
Aman Harwara
2021-10-19 21:37:47 +05:30
committed by GitHub
parent 3a4e2509af
commit 397e4963bd
11 changed files with 550 additions and 66 deletions

View File

@@ -48,11 +48,15 @@ import ServerIcon from '../../icons/ic-server.svg';
import EyeIcon from '../../icons/ic-eye.svg';
import EyeOffIcon from '../../icons/ic-eye-off.svg';
import LockIcon from '../../icons/ic-lock.svg';
import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg';
import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg';
import { toDirective } from './utils';
import { FunctionalComponent } from 'preact';
const ICONS = {
'arrows-sort-up': ArrowsSortUpIcon,
'arrows-sort-down': ArrowsSortDownIcon,
lock: LockIcon,
eye: EyeIcon,
'eye-off': EyeOffIcon,

View File

@@ -0,0 +1,244 @@
import { WebApplication } from '@/ui_models/application';
import { CollectionSort, PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { Icon } from './Icon';
import { Menu } from './menu/Menu';
import { MenuItem, MenuItemSeparator, MenuItemType } from './menu/MenuItem';
import { toDirective } from './utils';
type Props = {
application: WebApplication;
setShowMenuFalse: () => void;
};
export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
({ setShowMenuFalse, application }) => {
const menuClassName =
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
border-1 border-solid border-gray-300 text-sm z-index-dropdown-menu \
flex flex-col py-2 bottom-0 left-2 absolute';
const [sortBy, setSortBy] = useState(() =>
application.getPreference(PrefKey.SortNotesBy, CollectionSort.CreatedAt)
);
const [sortReverse, setSortReverse] = useState(() =>
application.getPreference(PrefKey.SortNotesReverse, false)
);
const [hidePreview, setHidePreview] = useState(() =>
application.getPreference(PrefKey.NotesHideNotePreview, false)
);
const [hideDate, setHideDate] = useState(() =>
application.getPreference(PrefKey.NotesHideDate, false)
);
const [hideTags, setHideTags] = useState(() =>
application.getPreference(PrefKey.NotesHideTags, true)
);
const [hidePinned, setHidePinned] = useState(() =>
application.getPreference(PrefKey.NotesHidePinned, false)
);
const [showArchived, setShowArchived] = useState(() =>
application.getPreference(PrefKey.NotesShowArchived, false)
);
const [showTrashed, setShowTrashed] = useState(() =>
application.getPreference(PrefKey.NotesShowTrashed, false)
);
const [hideProtected, setHideProtected] = useState(() =>
application.getPreference(PrefKey.NotesHideProtected, false)
);
const toggleSortReverse = () => {
application.setPreference(PrefKey.SortNotesReverse, !sortReverse);
setSortReverse(!sortReverse);
};
const toggleSortBy = (sort: CollectionSort) => {
if (sortBy === sort) {
toggleSortReverse();
} else {
setSortBy(sort);
application.setPreference(PrefKey.SortNotesBy, sort);
}
};
const toggleSortByDateModified = () => {
toggleSortBy(CollectionSort.UpdatedAt);
};
const toggleSortByCreationDate = () => {
toggleSortBy(CollectionSort.CreatedAt);
};
const toggleSortByTitle = () => {
toggleSortBy(CollectionSort.Title);
};
const toggleHidePreview = () => {
setHidePreview(!hidePreview);
application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview);
};
const toggleHideDate = () => {
setHideDate(!hideDate);
application.setPreference(PrefKey.NotesHideDate, !hideDate);
};
const toggleHideTags = () => {
setHideTags(!hideTags);
application.setPreference(PrefKey.NotesHideTags, !hideTags);
};
const toggleHidePinned = () => {
setHidePinned(!hidePinned);
application.setPreference(PrefKey.NotesHidePinned, !hidePinned);
};
const toggleShowArchived = () => {
setShowArchived(!showArchived);
application.setPreference(PrefKey.NotesShowArchived, !showArchived);
};
const toggleShowTrashed = () => {
setShowTrashed(!showTrashed);
application.setPreference(PrefKey.NotesShowTrashed, !showTrashed);
};
const toggleHideProtected = () => {
setHideProtected(!hideProtected);
application.setPreference(PrefKey.NotesHideProtected, !hideProtected);
};
return (
<div className={menuClassName}>
<Menu a11yLabel="Sort by" closeMenu={setShowMenuFalse}>
<div className="px-3 my-1 text-xs font-semibold color-text uppercase">
Sort by
</div>
<MenuItem
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByDateModified}
checked={sortBy === CollectionSort.UpdatedAt}
>
<div className="flex flex-grow items-center justify-between">
<span>Date modified</span>
{sortBy === CollectionSort.UpdatedAt ? (
sortReverse ? (
<Icon type="arrows-sort-up" className="color-neutral" />
) : (
<Icon type="arrows-sort-down" className="color-neutral" />
)
) : null}
</div>
</MenuItem>
<MenuItem
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByCreationDate}
checked={sortBy === CollectionSort.CreatedAt}
>
<div className="flex flex-grow items-center justify-between">
<span>Creation date</span>
{sortBy === CollectionSort.CreatedAt ? (
sortReverse ? (
<Icon type="arrows-sort-up" className="color-neutral" />
) : (
<Icon type="arrows-sort-down" className="color-neutral" />
)
) : null}
</div>
</MenuItem>
<MenuItem
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByTitle}
checked={sortBy === CollectionSort.Title}
>
<div className="flex flex-grow items-center justify-between">
<span>Title</span>
{sortBy === CollectionSort.Title ? (
sortReverse ? (
<Icon type="arrows-sort-up" className="color-neutral" />
) : (
<Icon type="arrows-sort-down" className="color-neutral" />
)
) : null}
</div>
</MenuItem>
<MenuItemSeparator />
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">
View
</div>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hidePreview}
onChange={toggleHidePreview}
>
<div className="flex flex-col max-w-3/4">Show note preview</div>
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideDate}
onChange={toggleHideDate}
>
Show date
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideTags}
onChange={toggleHideTags}
>
Show tags
</MenuItem>
<div className="h-1px my-2 bg-border"></div>
<div className="px-3 py-1 text-xs font-semibold color-text uppercase">
Other
</div>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hidePinned}
onChange={toggleHidePinned}
>
Show pinned notes
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideProtected}
onChange={toggleHideProtected}
>
Show protected notes
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={showArchived}
onChange={toggleShowArchived}
>
Show archived notes
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={showTrashed}
onChange={toggleShowTrashed}
>
Show trashed notes
</MenuItem>
</Menu>
</div>
);
}
);
export const NotesListOptionsDirective = toDirective<Props>(
NotesListOptionsMenu,
{
setShowMenuFalse: '=',
state: '&',
}
);

View File

@@ -13,6 +13,7 @@ export type SwitchProps = HTMLProps<HTMLInputElement> & {
onChange: (checked: boolean) => void;
className?: string;
children?: ComponentChildren;
role?: string;
};
export const Switch: FunctionalComponent<SwitchProps> = (
@@ -24,6 +25,7 @@ export const Switch: FunctionalComponent<SwitchProps> = (
return (
<label
className={`sn-component flex justify-between items-center cursor-pointer px-3 ${className}`}
{...(props.role ? { role: props.role } : {})}
>
{props.children}
<CustomCheckboxContainer

View File

@@ -0,0 +1,132 @@
import {
JSX,
FunctionComponent,
Ref,
ComponentChildren,
VNode,
RefCallback,
ComponentChild,
} from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { JSXInternal } from 'preact/src/jsx';
import { forwardRef } from 'preact/compat';
import { MenuItem, MenuItemListElement } from './MenuItem';
type MenuProps = {
className?: string;
style?: string | JSX.CSSProperties | undefined;
a11yLabel: string;
children: ComponentChildren;
closeMenu: () => void;
};
export const Menu: FunctionComponent<MenuProps> = forwardRef(
(
{ children, className = '', style, a11yLabel, closeMenu },
ref: Ref<HTMLMenuElement>
) => {
const [currentIndex, setCurrentIndex] = useState(0);
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown: JSXInternal.KeyboardEventHandler<HTMLMenuElement> = (
event
) => {
switch (event.key) {
case 'Home':
setCurrentIndex(0);
break;
case 'End':
setCurrentIndex(
menuItemRefs.current.length ? menuItemRefs.current.length - 1 : 0
);
break;
case 'ArrowDown':
setCurrentIndex((index) => {
if (index + 1 < menuItemRefs.current.length) {
return index + 1;
} else {
return 0;
}
});
break;
case 'ArrowUp':
setCurrentIndex((index) => {
if (index - 1 > -1) {
return index - 1;
} else {
return menuItemRefs.current.length - 1;
}
});
break;
case 'Escape':
closeMenu();
break;
}
};
useEffect(() => {
if (menuItemRefs.current[currentIndex]) {
menuItemRefs.current[currentIndex]?.focus();
}
}, [currentIndex]);
const pushRefToArray: RefCallback<HTMLLIElement> = (instance) => {
if (instance && instance.children) {
Array.from(instance.children).forEach((child) => {
if (
child.getAttribute('role')?.includes('menuitem') &&
!menuItemRefs.current.includes(child as HTMLButtonElement)
) {
menuItemRefs.current.push(child as HTMLButtonElement);
}
});
}
};
const mapMenuItems = (
child: ComponentChild,
index: number,
array: ComponentChild[]
) => {
if (!child) return;
const _child = child as VNode<unknown>;
const isFirstMenuItem =
index ===
array.findIndex((child) => (child as VNode<unknown>).type === MenuItem);
const hasMultipleItems = Array.isArray(_child.props.children)
? Array.from(_child.props.children as ComponentChild[]).some(
(child) => (child as VNode<unknown>).type === MenuItem
)
: false;
const items = hasMultipleItems
? [...(_child.props.children as ComponentChild[])]
: [_child];
return items.map((child) => {
return (
<MenuItemListElement
isFirstMenuItem={isFirstMenuItem}
ref={pushRefToArray}
>
{child}
</MenuItemListElement>
);
});
};
return (
<menu
className={`m-0 p-0 list-style-none ${className}`}
onKeyDown={handleKeyDown}
ref={ref}
style={style}
aria-label={a11yLabel}
>
{Array.isArray(children) ? children.map(mapMenuItems) : null}
</menu>
);
}
);

View File

@@ -0,0 +1,111 @@
import {
ComponentChild,
ComponentChildren,
FunctionComponent,
VNode,
} from 'preact';
import { forwardRef, Ref } from 'preact/compat';
import { JSXInternal } from 'preact/src/jsx';
import { Icon, IconType } from '../Icon';
import { Switch, SwitchProps } from '../Switch';
export enum MenuItemType {
IconButton,
RadioButton,
SwitchButton,
}
type MenuItemProps = {
type: MenuItemType;
children: ComponentChildren;
onClick?: JSXInternal.MouseEventHandler<HTMLButtonElement>;
onChange?: SwitchProps['onChange'];
className?: string;
checked?: boolean;
icon?: IconType;
iconClassName?: string;
tabIndex?: number;
};
export const MenuItem: FunctionComponent<MenuItemProps> = forwardRef(
(
{
children,
onClick,
onChange,
className = '',
type,
checked,
icon,
iconClassName,
tabIndex,
},
ref: Ref<HTMLButtonElement>
) => {
return type === MenuItemType.SwitchButton &&
typeof onChange === 'function' ? (
<Switch
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={checked}
onChange={onChange}
role="menuitemcheckbox"
tabIndex={typeof tabIndex === 'number' ? tabIndex : -1}
>
{children}
</Switch>
) : (
<button
ref={ref}
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
tabIndex={typeof tabIndex === 'number' ? tabIndex : -1}
className={`sn-dropdown-item focus:bg-info-backdrop focus:shadow-none ${className}`}
onClick={onClick}
{...(type === MenuItemType.RadioButton
? { 'aria-checked': checked }
: {})}
>
{type === MenuItemType.IconButton && icon ? (
<Icon type={icon} className={iconClassName} />
) : null}
{type === MenuItemType.RadioButton && typeof checked === 'boolean' ? (
<div
className={`pseudo-radio-btn ${
checked ? 'pseudo-radio-btn--checked' : ''
} mr-2`}
></div>
) : null}
{children}
</button>
);
}
);
export const MenuItemSeparator: FunctionComponent = () => (
<div role="separator" className="h-1px my-2 bg-border"></div>
);
type ListElementProps = {
isFirstMenuItem: boolean;
children: ComponentChildren;
};
export const MenuItemListElement: FunctionComponent<ListElementProps> =
forwardRef(({ children, isFirstMenuItem }, ref: Ref<HTMLLIElement>) => {
const child = children as VNode<unknown>;
return (
<li className="list-style-none" role="none" ref={ref}>
{{
...child,
props: {
...(child.props ? { ...child.props } : {}),
...(child.type === MenuItem
? {
tabIndex: isFirstMenuItem ? 0 : -1,
}
: {}),
},
}}
</li>
);
});