feat: replace accordion in change editor menu with regular menu (#871)
This commit is contained in:
@@ -107,7 +107,11 @@ export const GeneralAccountMenu: FunctionComponent<Props> = observer(
|
||||
</>
|
||||
)}
|
||||
<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 ? (
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
|
||||
@@ -11,10 +11,11 @@ type Props = {
|
||||
application: WebApplication;
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
closeDisplayOptionsMenu: () => void;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
|
||||
({ closeDisplayOptionsMenu, closeOnBlur, application }) => {
|
||||
({ closeDisplayOptionsMenu, closeOnBlur, application, isOpen }) => {
|
||||
const menuClassName =
|
||||
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
|
||||
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 (
|
||||
<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">
|
||||
Sort by
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { KeyboardKey } from '@/services/ioService';
|
||||
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/strings';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import {
|
||||
MENU_MARGIN_FROM_APP_BORDER,
|
||||
MAX_MENU_SIZE_MULTIPLIER,
|
||||
} from '@/views/constants';
|
||||
import {
|
||||
reloadFont,
|
||||
transactionForAssociateComponentWithCurrentNote,
|
||||
transactionForDisassociateComponentWithCurrentNote,
|
||||
} from '@/components/NoteView/NoteView';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
@@ -19,18 +13,14 @@ import {
|
||||
import {
|
||||
ComponentArea,
|
||||
IconType,
|
||||
ItemMutator,
|
||||
NoteMutator,
|
||||
PrefKey,
|
||||
SNComponent,
|
||||
SNNote,
|
||||
TransactionalMutation,
|
||||
} from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Icon } from '../Icon';
|
||||
import { createEditorMenuGroups } from './changeEditor/createEditorMenuGroups';
|
||||
import { EditorAccordionMenu } from './changeEditor/EditorAccordionMenu';
|
||||
import { ChangeEditorMenu } from './changeEditor/ChangeEditorMenu';
|
||||
|
||||
type ChangeEditorOptionProps = {
|
||||
appState: AppState;
|
||||
@@ -59,6 +49,7 @@ type MenuPositionStyle = {
|
||||
right?: number | 'auto';
|
||||
bottom: number | 'auto';
|
||||
left?: number | 'auto';
|
||||
visibility?: 'hidden' | 'visible';
|
||||
};
|
||||
|
||||
const calculateMenuPosition = (
|
||||
@@ -102,11 +93,13 @@ const calculateMenuPosition = (
|
||||
position = {
|
||||
bottom: positionBottom,
|
||||
right: clientWidth - buttonRect.left,
|
||||
visibility: 'hidden',
|
||||
};
|
||||
} else {
|
||||
position = {
|
||||
bottom: positionBottom,
|
||||
left: buttonRect.right,
|
||||
visibility: 'hidden',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -121,12 +114,14 @@ const calculateMenuPosition = (
|
||||
...position,
|
||||
top: MENU_MARGIN_FROM_APP_BORDER + buttonRect.top - buttonRect.height,
|
||||
bottom: 'auto',
|
||||
visibility: 'visible',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...position,
|
||||
top: MENU_MARGIN_FROM_APP_BORDER,
|
||||
bottom: 'auto',
|
||||
visibility: 'visible',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -135,15 +130,16 @@ const calculateMenuPosition = (
|
||||
return position;
|
||||
};
|
||||
|
||||
const TIME_IN_MS_TO_WAIT_BEFORE_REPAINT = 1;
|
||||
|
||||
export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
|
||||
application,
|
||||
appState,
|
||||
closeOnBlur,
|
||||
note,
|
||||
}) => {
|
||||
const [changeEditorMenuOpen, setChangeEditorMenuOpen] = useState(false);
|
||||
const [changeEditorMenuVisible, setChangeEditorMenuVisible] = useState(false);
|
||||
const [changeEditorMenuMaxHeight, setChangeEditorMenuMaxHeight] = useState<
|
||||
number | 'auto'
|
||||
>('auto');
|
||||
const [changeEditorMenuPosition, setChangeEditorMenuPosition] =
|
||||
useState<MenuPositionStyle>({
|
||||
right: 0,
|
||||
@@ -193,84 +189,29 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
|
||||
);
|
||||
|
||||
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);
|
||||
setChangeEditorMenuVisible(true);
|
||||
}
|
||||
}, TIME_IN_MS_TO_WAIT_BEFORE_REPAINT);
|
||||
});
|
||||
}
|
||||
}, [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 (
|
||||
<Disclosure open={changeEditorMenuOpen} onChange={toggleChangeEditorMenu}>
|
||||
<DisclosureButton
|
||||
@@ -299,17 +240,19 @@ export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
|
||||
}}
|
||||
style={{
|
||||
...changeEditorMenuPosition,
|
||||
maxHeight: changeEditorMenuMaxHeight,
|
||||
position: 'fixed',
|
||||
}}
|
||||
className="sn-dropdown flex flex-col max-h-120 min-w-68 fixed overflow-y-auto"
|
||||
>
|
||||
<EditorAccordionMenu
|
||||
<ChangeEditorMenu
|
||||
application={application}
|
||||
closeOnBlur={closeOnBlur}
|
||||
currentEditor={selectedEditor}
|
||||
setSelectedEditor={setSelectedEditor}
|
||||
note={note}
|
||||
groups={editorMenuGroups}
|
||||
isOpen={changeEditorMenuOpen}
|
||||
selectComponent={selectComponent}
|
||||
isOpen={changeEditorMenuVisible}
|
||||
/>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
import { ContentType, SNComponent } from '@standardnotes/snjs';
|
||||
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
|
||||
|
||||
/** @todo Implement interchangeable alert */
|
||||
|
||||
export const PLAIN_EDITOR_NAME = 'Plain Editor';
|
||||
|
||||
type EditorGroup = NoteType | 'plain' | 'others';
|
||||
|
||||
@@ -238,6 +238,7 @@ export const NotesView: FunctionComponent<Props> = observer(
|
||||
application={application}
|
||||
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
|
||||
closeOnBlur={closeDisplayOptMenuOnBlur}
|
||||
isOpen={showDisplayOptionsMenu}
|
||||
/>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
JSX,
|
||||
FunctionComponent,
|
||||
Ref,
|
||||
ComponentChildren,
|
||||
VNode,
|
||||
RefCallback,
|
||||
@@ -9,124 +8,133 @@ import {
|
||||
} 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';
|
||||
import { KeyboardKey } from '@/services/ioService';
|
||||
|
||||
type MenuProps = {
|
||||
className?: string;
|
||||
style?: string | JSX.CSSProperties | undefined;
|
||||
a11yLabel: string;
|
||||
children: ComponentChildren;
|
||||
closeMenu: () => void;
|
||||
closeMenu?: () => void;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
export const Menu: FunctionComponent<MenuProps> = forwardRef(
|
||||
(
|
||||
{ children, className = '', style, a11yLabel, closeMenu }: MenuProps,
|
||||
ref: Ref<HTMLMenuElement>
|
||||
export const Menu: FunctionComponent<MenuProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
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);
|
||||
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
if (!menuItemRefs.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
switch (event.key) {
|
||||
case KeyboardKey.Home:
|
||||
setCurrentIndex(0);
|
||||
break;
|
||||
case KeyboardKey.End:
|
||||
setCurrentIndex(
|
||||
menuItemRefs.current.length ? menuItemRefs.current.length - 1 : 0
|
||||
);
|
||||
break;
|
||||
case KeyboardKey.Down:
|
||||
setCurrentIndex((index) => {
|
||||
if (index + 1 < menuItemRefs.current.length) {
|
||||
return index + 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
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 = (
|
||||
child: ComponentChild,
|
||||
index: number,
|
||||
array: ComponentChild[]
|
||||
) => {
|
||||
if (!child) return;
|
||||
useEffect(() => {
|
||||
if (isOpen && menuItemRefs.current[currentIndex]) {
|
||||
menuItemRefs.current[currentIndex]?.focus();
|
||||
}
|
||||
}, [currentIndex, isOpen]);
|
||||
|
||||
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>
|
||||
);
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
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={menuElementRef}
|
||||
style={style}
|
||||
aria-label={a11yLabel}
|
||||
>
|
||||
{Array.isArray(children) ? children.map(mapMenuItems) : null}
|
||||
</menu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ export enum KeyboardKey {
|
||||
Down = 'ArrowDown',
|
||||
Enter = 'Enter',
|
||||
Escape = 'Escape',
|
||||
Home = 'Home',
|
||||
End = 'End',
|
||||
}
|
||||
|
||||
export enum KeyboardModifier {
|
||||
|
||||
@@ -450,6 +450,10 @@
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.sn-component .pt-0\.5 {
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.pt-1 {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
@@ -470,7 +474,7 @@
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.pb-1 {
|
||||
.sn-component .pb-1 {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -487,6 +491,11 @@
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.px-2\.5 {
|
||||
padding-left: 0.625rem;
|
||||
padding-right: 0.625rem;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
@@ -853,7 +862,7 @@
|
||||
}
|
||||
|
||||
.dimmed {
|
||||
opacity: .5;
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -865,3 +874,12 @@
|
||||
.bg-note-size-warning {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user