feat: replace accordion in change editor menu with regular menu (#871)

This commit is contained in:
Aman Harwara
2022-02-16 17:57:06 +05:30
committed by GitHub
parent cb013dbce9
commit cc2bc1e21c
10 changed files with 428 additions and 487 deletions

View File

@@ -107,7 +107,11 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
</> </>
)} )}
<div className="h-1px my-2 bg-border"></div> <div className="h-1px my-2 bg-border"></div>
<Menu a11yLabel="General account menu" closeMenu={closeMenu}> <Menu
isOpen={appState.accountMenu.show}
a11yLabel="General account menu"
closeMenu={closeMenu}
>
{user ? ( {user ? (
<MenuItem <MenuItem
type={MenuItemType.IconButton} type={MenuItemType.IconButton}

View File

@@ -11,10 +11,11 @@ type Props = {
application: WebApplication; application: WebApplication;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
closeDisplayOptionsMenu: () => void; closeDisplayOptionsMenu: () => void;
isOpen: boolean;
}; };
export const NotesListOptionsMenu: FunctionComponent<Props> = observer( export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
({ closeDisplayOptionsMenu, closeOnBlur, application }) => { ({ closeDisplayOptionsMenu, closeOnBlur, application, isOpen }) => {
const menuClassName = const menuClassName =
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \ 'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
border-1 border-solid border-main text-sm z-index-dropdown-menu \ border-1 border-solid border-main text-sm z-index-dropdown-menu \
@@ -120,7 +121,11 @@ flex flex-col py-2 top-full bottom-0 left-2 absolute';
return ( return (
<div ref={menuRef} className={menuClassName}> <div ref={menuRef} className={menuClassName}>
<Menu a11yLabel="Sort by" closeMenu={closeDisplayOptionsMenu}> <Menu
a11yLabel="Notes list options menu"
closeMenu={closeDisplayOptionsMenu}
isOpen={isOpen}
>
<div className="px-3 my-1 text-xs font-semibold color-text uppercase"> <div className="px-3 my-1 text-xs font-semibold color-text uppercase">
Sort by Sort by
</div> </div>

View File

@@ -1,16 +1,10 @@
import { KeyboardKey } from '@/services/ioService'; import { KeyboardKey } from '@/services/ioService';
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/strings';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { import {
MENU_MARGIN_FROM_APP_BORDER, MENU_MARGIN_FROM_APP_BORDER,
MAX_MENU_SIZE_MULTIPLIER, MAX_MENU_SIZE_MULTIPLIER,
} from '@/views/constants'; } from '@/views/constants';
import {
reloadFont,
transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote,
} from '@/components/NoteView/NoteView';
import { import {
Disclosure, Disclosure,
DisclosureButton, DisclosureButton,
@@ -19,18 +13,14 @@ import {
import { import {
ComponentArea, ComponentArea,
IconType, IconType,
ItemMutator,
NoteMutator,
PrefKey,
SNComponent, SNComponent,
SNNote, SNNote,
TransactionalMutation,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
import { createEditorMenuGroups } from './changeEditor/createEditorMenuGroups'; import { createEditorMenuGroups } from './changeEditor/createEditorMenuGroups';
import { EditorAccordionMenu } from './changeEditor/EditorAccordionMenu'; import { ChangeEditorMenu } from './changeEditor/ChangeEditorMenu';
type ChangeEditorOptionProps = { type ChangeEditorOptionProps = {
appState: AppState; appState: AppState;
@@ -59,6 +49,7 @@ type MenuPositionStyle = {
right?: number | 'auto'; right?: number | 'auto';
bottom: number | 'auto'; bottom: number | 'auto';
left?: number | 'auto'; left?: number | 'auto';
visibility?: 'hidden' | 'visible';
}; };
const calculateMenuPosition = ( const calculateMenuPosition = (
@@ -102,11 +93,13 @@ const calculateMenuPosition = (
position = { position = {
bottom: positionBottom, bottom: positionBottom,
right: clientWidth - buttonRect.left, right: clientWidth - buttonRect.left,
visibility: 'hidden',
}; };
} else { } else {
position = { position = {
bottom: positionBottom, bottom: positionBottom,
left: buttonRect.right, left: buttonRect.right,
visibility: 'hidden',
}; };
} }
} }
@@ -121,12 +114,14 @@ const calculateMenuPosition = (
...position, ...position,
top: MENU_MARGIN_FROM_APP_BORDER + buttonRect.top - buttonRect.height, top: MENU_MARGIN_FROM_APP_BORDER + buttonRect.top - buttonRect.height,
bottom: 'auto', bottom: 'auto',
visibility: 'visible',
}; };
} else { } else {
return { return {
...position, ...position,
top: MENU_MARGIN_FROM_APP_BORDER, top: MENU_MARGIN_FROM_APP_BORDER,
bottom: 'auto', bottom: 'auto',
visibility: 'visible',
}; };
} }
} }
@@ -135,15 +130,16 @@ const calculateMenuPosition = (
return position; return position;
}; };
const TIME_IN_MS_TO_WAIT_BEFORE_REPAINT = 1;
export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
application, application,
appState,
closeOnBlur, closeOnBlur,
note, note,
}) => { }) => {
const [changeEditorMenuOpen, setChangeEditorMenuOpen] = useState(false); const [changeEditorMenuOpen, setChangeEditorMenuOpen] = useState(false);
const [changeEditorMenuVisible, setChangeEditorMenuVisible] = useState(false);
const [changeEditorMenuMaxHeight, setChangeEditorMenuMaxHeight] = useState<
number | 'auto'
>('auto');
const [changeEditorMenuPosition, setChangeEditorMenuPosition] = const [changeEditorMenuPosition, setChangeEditorMenuPosition] =
useState<MenuPositionStyle>({ useState<MenuPositionStyle>({
right: 0, right: 0,
@@ -193,84 +189,29 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
); );
if (newMenuPosition) { if (newMenuPosition) {
const { clientHeight } = document.documentElement;
const footerElementRect = document
.getElementById('footer-bar')
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height;
if (
footerHeightInPx &&
newMenuPosition.top &&
newMenuPosition.top !== 'auto'
) {
setChangeEditorMenuMaxHeight(
clientHeight - newMenuPosition.top - footerHeightInPx - 2
);
}
setChangeEditorMenuPosition(newMenuPosition); setChangeEditorMenuPosition(newMenuPosition);
setChangeEditorMenuVisible(true);
} }
}, TIME_IN_MS_TO_WAIT_BEFORE_REPAINT); });
} }
}, [changeEditorMenuOpen]); }, [changeEditorMenuOpen]);
const selectComponent = async (component: SNComponent | null) => {
if (component) {
if (component.conflictOf) {
application.changeAndSaveItem(component.uuid, (mutator) => {
mutator.conflictOf = undefined;
});
}
}
const transactions: TransactionalMutation[] = [];
if (appState.getActiveNoteController()?.isTemplateNote) {
await appState.getActiveNoteController().insertTemplatedNote();
}
if (note.locked) {
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT);
return;
}
if (!component) {
if (!note.prefersPlainEditor) {
transactions.push({
itemUuid: note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator;
noteMutator.prefersPlainEditor = true;
},
});
}
const currentEditor = application.componentManager.editorForNote(note);
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
transactions.push(
transactionForDisassociateComponentWithCurrentNote(
currentEditor,
note
)
);
}
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled));
} else if (component.area === ComponentArea.Editor) {
const currentEditor = application.componentManager.editorForNote(note);
if (currentEditor && component.uuid !== currentEditor.uuid) {
transactions.push(
transactionForDisassociateComponentWithCurrentNote(
currentEditor,
note
)
);
}
const prefersPlain = note.prefersPlainEditor;
if (prefersPlain) {
transactions.push({
itemUuid: note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator;
noteMutator.prefersPlainEditor = false;
},
});
}
transactions.push(
transactionForAssociateComponentWithCurrentNote(component, note)
);
}
await application.runTransactionalMutations(transactions);
/** Dirtying can happen above */
application.sync();
setSelectedEditor(application.componentManager.editorForNote(note));
};
return ( return (
<Disclosure open={changeEditorMenuOpen} onChange={toggleChangeEditorMenu}> <Disclosure open={changeEditorMenuOpen} onChange={toggleChangeEditorMenu}>
<DisclosureButton <DisclosureButton
@@ -299,17 +240,19 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
}} }}
style={{ style={{
...changeEditorMenuPosition, ...changeEditorMenuPosition,
maxHeight: changeEditorMenuMaxHeight,
position: 'fixed', position: 'fixed',
}} }}
className="sn-dropdown flex flex-col max-h-120 min-w-68 fixed overflow-y-auto" className="sn-dropdown flex flex-col max-h-120 min-w-68 fixed overflow-y-auto"
> >
<EditorAccordionMenu <ChangeEditorMenu
application={application} application={application}
closeOnBlur={closeOnBlur} closeOnBlur={closeOnBlur}
currentEditor={selectedEditor} currentEditor={selectedEditor}
setSelectedEditor={setSelectedEditor}
note={note}
groups={editorMenuGroups} groups={editorMenuGroups}
isOpen={changeEditorMenuOpen} isOpen={changeEditorMenuVisible}
selectComponent={selectComponent}
/> />
</DisclosurePanel> </DisclosurePanel>
</Disclosure> </Disclosure>

View File

@@ -0,0 +1,247 @@
import { Icon } from '@/components/Icon';
import { Menu } from '@/components/menu/Menu';
import { MenuItem, MenuItemType } from '@/components/menu/MenuItem';
import {
reloadFont,
transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote,
} from '@/components/NoteView/NoteView';
import { usePremiumModal } from '@/components/Premium';
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/strings';
import { WebApplication } from '@/ui_models/application';
import {
ComponentArea,
FeatureStatus,
ItemMutator,
NoteMutator,
PrefKey,
SNComponent,
SNNote,
TransactionalMutation,
} from '@standardnotes/snjs';
import { Fragment, FunctionComponent } from 'preact';
import { StateUpdater, useCallback } from 'preact/hooks';
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
import { PLAIN_EDITOR_NAME } from './createEditorMenuGroups';
type ChangeEditorMenuProps = {
application: WebApplication;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
groups: EditorMenuGroup[];
isOpen: boolean;
currentEditor: SNComponent | undefined;
note: SNNote;
setSelectedEditor: StateUpdater<SNComponent | undefined>;
};
const getGroupId = (group: EditorMenuGroup) =>
group.title.toLowerCase().replace(/\s/, '-');
export const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
application,
closeOnBlur,
groups,
isOpen,
currentEditor,
setSelectedEditor,
note,
}) => {
const premiumModal = usePremiumModal();
const isEntitledToEditor = useCallback(
(item: EditorMenuItem) => {
const isPlainEditor = !item.component;
if (item.isPremiumFeature) {
return false;
}
if (isPlainEditor) {
return true;
}
if (item.component) {
return (
application.getFeatureStatus(item.component.identifier) ===
FeatureStatus.Entitled
);
}
},
[application]
);
const isSelectedEditor = useCallback(
(item: EditorMenuItem) => {
if (currentEditor) {
if (item?.component?.identifier === currentEditor.identifier) {
return true;
}
} else if (item.name === PLAIN_EDITOR_NAME) {
return true;
}
return false;
},
[currentEditor]
);
const selectComponent = async (
component: SNComponent | null,
note: SNNote
) => {
if (component) {
if (component.conflictOf) {
application.changeAndSaveItem(component.uuid, (mutator) => {
mutator.conflictOf = undefined;
});
}
}
const transactions: TransactionalMutation[] = [];
if (application.getAppState().getActiveNoteController()?.isTemplateNote) {
await application
.getAppState()
.getActiveNoteController()
.insertTemplatedNote();
}
if (note.locked) {
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT);
return;
}
if (!component) {
if (!note.prefersPlainEditor) {
transactions.push({
itemUuid: note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator;
noteMutator.prefersPlainEditor = true;
},
});
}
const currentEditor = application.componentManager.editorForNote(note);
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
transactions.push(
transactionForDisassociateComponentWithCurrentNote(
currentEditor,
note
)
);
}
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled));
} else if (component.area === ComponentArea.Editor) {
const currentEditor = application.componentManager.editorForNote(note);
if (currentEditor && component.uuid !== currentEditor.uuid) {
transactions.push(
transactionForDisassociateComponentWithCurrentNote(
currentEditor,
note
)
);
}
const prefersPlain = note.prefersPlainEditor;
if (prefersPlain) {
transactions.push({
itemUuid: note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator;
noteMutator.prefersPlainEditor = false;
},
});
}
transactions.push(
transactionForAssociateComponentWithCurrentNote(component, note)
);
}
await application.runTransactionalMutations(transactions);
/** Dirtying can happen above */
application.sync();
setSelectedEditor(application.componentManager.editorForNote(note));
};
const selectEditor = async (itemToBeSelected: EditorMenuItem) => {
let shouldSelectEditor = true;
if (itemToBeSelected.component) {
const changeRequiresAlert =
application.componentManager.doesEditorChangeRequireAlert(
currentEditor,
itemToBeSelected.component
);
if (changeRequiresAlert) {
shouldSelectEditor =
await application.componentManager.showEditorChangeAlert();
}
}
if (
itemToBeSelected.isPremiumFeature ||
!isEntitledToEditor(itemToBeSelected)
) {
premiumModal.activate(itemToBeSelected.name);
shouldSelectEditor = false;
}
if (shouldSelectEditor) {
selectComponent(itemToBeSelected.component ?? null, note);
}
};
return (
<Menu
className="pt-0.5 pb-1"
a11yLabel="Change editor menu"
isOpen={isOpen}
>
{groups
.filter((group) => group.items && group.items.length)
.map((group, index) => {
const groupId = getGroupId(group);
return (
<Fragment key={groupId}>
<div
className={`flex items-center px-2.5 py-2 text-xs font-semibold color-text border-0 border-y-1px border-solid border-main ${
index === 0 ? 'border-t-0 mb-2' : 'my-2'
}`}
>
{group.icon && (
<Icon
type={group.icon}
className={`mr-2 ${group.iconClassName}`}
/>
)}
<div className="font-semibold text-input">{group.title}</div>
</div>
{group.items.map((item) => {
const onClickEditorItem = () => {
selectEditor(item);
};
return (
<MenuItem
type={MenuItemType.RadioButton}
onClick={onClickEditorItem}
className={`sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none`}
onBlur={closeOnBlur}
checked={isSelectedEditor(item)}
>
<div className="flex flex-grow items-center justify-between">
{item.name}
{(item.isPremiumFeature || !isEntitledToEditor(item)) && (
<Icon type="premium-feature" />
)}
</div>
</MenuItem>
);
})}
</Fragment>
);
})}
</Menu>
);
};

View File

@@ -1,285 +0,0 @@
import { Icon } from '@/components/Icon';
import { usePremiumModal } from '@/components/Premium';
import { KeyboardKey } from '@/services/ioService';
import { WebApplication } from '@/ui_models/application';
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/views/constants';
import { SNComponent } from '@standardnotes/snjs';
import { Fragment, FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
import { PLAIN_EDITOR_NAME } from './createEditorMenuGroups';
type EditorAccordionMenuProps = {
application: WebApplication;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
groups: EditorMenuGroup[];
isOpen: boolean;
selectComponent: (component: SNComponent | null) => Promise<void>;
currentEditor: SNComponent | undefined;
};
const getGroupId = (group: EditorMenuGroup) =>
group.title.toLowerCase().replace(/\s/, '-');
const getGroupBtnId = (groupId: string) => groupId + '-button';
const isElementHidden = (element: Element) => !element.clientHeight;
const isElementFocused = (element: Element | null) =>
element === document.activeElement;
export const EditorAccordionMenu: FunctionComponent<
EditorAccordionMenuProps
> = ({
application,
closeOnBlur,
groups,
isOpen,
selectComponent,
currentEditor,
}) => {
const [activeGroupId, setActiveGroupId] = useState('');
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const premiumModal = usePremiumModal();
const addRefToMenuItems = (button: HTMLButtonElement | null) => {
if (!menuItemRefs.current?.includes(button) && button) {
menuItemRefs.current.push(button);
}
};
const isSelectedEditor = useCallback(
(item: EditorMenuItem) => {
if (currentEditor) {
if (item?.component?.identifier === currentEditor.identifier) {
return true;
}
} else if (item.name === PLAIN_EDITOR_NAME) {
return true;
}
return false;
},
[currentEditor]
);
useEffect(() => {
const activeGroup = groups.find((group) => {
return group.items.some(isSelectedEditor);
});
if (activeGroup) {
const newActiveGroupId = getGroupId(activeGroup);
setActiveGroupId(newActiveGroupId);
}
}, [groups, currentEditor, isSelectedEditor]);
useEffect(() => {
if (isOpen && !menuItemRefs.current.some(isElementFocused)) {
const selectedEditor = groups
.map((group) => group.items)
.flat()
.find((item) => isSelectedEditor(item));
if (selectedEditor) {
const editorButton = menuItemRefs.current.find(
(btn) => btn?.dataset.itemName === selectedEditor.name
);
editorButton?.focus();
}
}
}, [groups, isOpen, isSelectedEditor]);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === KeyboardKey.Down || e.key === KeyboardKey.Up) {
e.preventDefault();
} else {
return;
}
let items = menuItemRefs.current;
if (!activeGroupId) {
items = items.filter((btn) => btn?.id);
}
const currentItemIndex = items.findIndex(isElementFocused) ?? 0;
if (e.key === KeyboardKey.Up) {
let previousItemIndex = currentItemIndex - 1;
if (previousItemIndex < 0) {
previousItemIndex = items.length - 1;
}
const previousItem = items[previousItemIndex];
if (previousItem) {
if (isElementHidden(previousItem)) {
const previousItemGroupId = previousItem.closest(
'[data-accordion-group]'
)?.id;
if (previousItemGroupId) {
setActiveGroupId(previousItemGroupId);
}
setTimeout(() => {
previousItem.focus();
}, 10);
}
previousItem.focus();
}
}
if (e.key === KeyboardKey.Down) {
let nextItemIndex = currentItemIndex + 1;
if (nextItemIndex > items.length - 1) {
nextItemIndex = 0;
}
const nextItem = items[nextItemIndex];
if (nextItem) {
if (isElementHidden(nextItem)) {
const nextItemGroupId = nextItem.closest(
'[data-accordion-group]'
)?.id;
if (nextItemGroupId) {
setActiveGroupId(nextItemGroupId);
}
setTimeout(() => {
nextItem.focus();
}, 10);
}
nextItem?.focus();
}
}
};
const selectEditor = async (itemToBeSelected: EditorMenuItem) => {
let shouldSelectEditor = true;
if (itemToBeSelected.component) {
const changeRequiresAlert =
application.componentManager.doesEditorChangeRequireAlert(
currentEditor,
itemToBeSelected.component
);
if (changeRequiresAlert) {
shouldSelectEditor =
await application.componentManager.showEditorChangeAlert();
}
}
if (itemToBeSelected.isPremiumFeature) {
premiumModal.activate(itemToBeSelected.name);
shouldSelectEditor = false;
}
if (shouldSelectEditor) {
selectComponent(itemToBeSelected.component ?? null);
}
};
return (
<>
{groups.map((group) => {
if (!group.items || !group.items.length) {
return null;
}
const groupId = getGroupId(group);
const buttonId = getGroupBtnId(groupId);
const contentId = `${groupId}-content`;
const toggleGroup = () => {
if (activeGroupId !== groupId) {
setActiveGroupId(groupId);
} else {
setActiveGroupId('');
}
};
return (
<Fragment key={groupId}>
<div
id={groupId}
data-accordion-group
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
onKeyDown={handleKeyDown}
>
<h3 className="m-0">
<button
aria-controls={contentId}
aria-expanded={activeGroupId === groupId}
className="sn-dropdown-item focus:bg-info-backdrop justify-between py-3"
id={buttonId}
type="button"
onClick={toggleGroup}
onBlur={closeOnBlur}
ref={addRefToMenuItems}
>
<div className="flex items-center">
{group.icon && (
<Icon
type={group.icon}
className={`mr-2 ${group.iconClassName}`}
/>
)}
<div className="font-semibold text-input">
{group.title}
</div>
</div>
<Icon
type="chevron-down"
className={`sn-dropdown-arrow color-grey-1 ${
activeGroupId === groupId && 'sn-dropdown-arrow-flipped'
}`}
/>
</button>
</h3>
<div
id={contentId}
aria-labelledby={buttonId}
className={activeGroupId !== groupId ? 'hidden' : ''}
>
<div role="radiogroup">
{group.items.map((item) => {
const onClickEditorItem = () => {
selectEditor(item);
};
return (
<button
role="radio"
data-item-name={item.name}
onClick={onClickEditorItem}
className={`sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none ${
item.isPremiumFeature && 'justify-between'
}`}
aria-checked={false}
onBlur={closeOnBlur}
ref={addRefToMenuItems}
>
<div className="flex items-center">
<div
className={`pseudo-radio-btn ${
isSelectedEditor(item)
? 'pseudo-radio-btn--checked'
: ''
} ml-0.5 mr-2`}
></div>
{item.name}
</div>
{item.isPremiumFeature && (
<Icon type="premium-feature" />
)}
</button>
);
})}
</div>
</div>
</div>
<div className="min-h-1px bg-border hide-if-last-child"></div>
</Fragment>
);
})}
</>
);
};

View File

@@ -7,8 +7,6 @@ import {
import { ContentType, SNComponent } from '@standardnotes/snjs'; import { ContentType, SNComponent } from '@standardnotes/snjs';
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption'; import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
/** @todo Implement interchangeable alert */
export const PLAIN_EDITOR_NAME = 'Plain Editor'; export const PLAIN_EDITOR_NAME = 'Plain Editor';
type EditorGroup = NoteType | 'plain' | 'others'; type EditorGroup = NoteType | 'plain' | 'others';

View File

@@ -238,6 +238,7 @@ export const NotesView: FunctionComponent<Props> = observer(
application={application} application={application}
closeDisplayOptionsMenu={toggleDisplayOptionsMenu} closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
closeOnBlur={closeDisplayOptMenuOnBlur} closeOnBlur={closeDisplayOptMenuOnBlur}
isOpen={showDisplayOptionsMenu}
/> />
)} )}
</DisclosurePanel> </DisclosurePanel>

View File

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

View File

@@ -6,6 +6,8 @@ export enum KeyboardKey {
Down = 'ArrowDown', Down = 'ArrowDown',
Enter = 'Enter', Enter = 'Enter',
Escape = 'Escape', Escape = 'Escape',
Home = 'Home',
End = 'End',
} }
export enum KeyboardModifier { export enum KeyboardModifier {

View File

@@ -450,6 +450,10 @@
padding: 3rem; padding: 3rem;
} }
.sn-component .pt-0\.5 {
padding-top: 0.125rem;
}
.pt-1 { .pt-1 {
padding-top: 0.25rem; padding-top: 0.25rem;
} }
@@ -470,7 +474,7 @@
padding-top: 1.5rem; padding-top: 1.5rem;
} }
.pb-1 { .sn-component .pb-1 {
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
@@ -487,6 +491,11 @@
padding-right: 0; padding-right: 0;
} }
.px-2\.5 {
padding-left: 0.625rem;
padding-right: 0.625rem;
}
.px-4 { .px-4 {
padding-left: 1rem; padding-left: 1rem;
padding-right: 1rem; padding-right: 1rem;
@@ -853,7 +862,7 @@
} }
.dimmed { .dimmed {
opacity: .5; opacity: 0.5;
cursor: default; cursor: default;
pointer-events: none; pointer-events: none;
} }
@@ -865,3 +874,12 @@
.bg-note-size-warning { .bg-note-size-warning {
background-color: rgba(235, 173, 0, 0.08); background-color: rgba(235, 173, 0, 0.08);
} }
.sn-component .border-y-1px {
border-top-width: 1px;
border-bottom-width: 1px;
}
.sn-component .border-t-0 {
border-top-width: 0;
}