From cc2bc1e21c971b6907090ae50eb04a291d8d22fe Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Wed, 16 Feb 2022 17:57:06 +0530 Subject: [PATCH] feat: replace accordion in change editor menu with regular menu (#871) --- .../AccountMenu/GeneralAccountMenu.tsx | 6 +- .../components/NotesListOptionsMenu.tsx | 9 +- .../NotesOptions/ChangeEditorOption.tsx | 123 ++------ .../changeEditor/ChangeEditorMenu.tsx | 247 +++++++++++++++ .../changeEditor/EditorAccordionMenu.tsx | 285 ------------------ .../changeEditor/createEditorMenuGroups.ts | 2 - .../javascripts/components/NotesView.tsx | 1 + .../javascripts/components/menu/Menu.tsx | 218 +++++++------- app/assets/javascripts/services/ioService.ts | 2 + app/assets/stylesheets/_sn.scss | 22 +- 10 files changed, 428 insertions(+), 487 deletions(-) create mode 100644 app/assets/javascripts/components/NotesOptions/changeEditor/ChangeEditorMenu.tsx delete mode 100644 app/assets/javascripts/components/NotesOptions/changeEditor/EditorAccordionMenu.tsx diff --git a/app/assets/javascripts/components/AccountMenu/GeneralAccountMenu.tsx b/app/assets/javascripts/components/AccountMenu/GeneralAccountMenu.tsx index 5b0378648..706b85e3c 100644 --- a/app/assets/javascripts/components/AccountMenu/GeneralAccountMenu.tsx +++ b/app/assets/javascripts/components/AccountMenu/GeneralAccountMenu.tsx @@ -107,7 +107,11 @@ export const GeneralAccountMenu: FunctionComponent = observer( )}
- + {user ? ( void; closeDisplayOptionsMenu: () => void; + isOpen: boolean; }; export const NotesListOptionsMenu: FunctionComponent = 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 (
- +
Sort by
diff --git a/app/assets/javascripts/components/NotesOptions/ChangeEditorOption.tsx b/app/assets/javascripts/components/NotesOptions/ChangeEditorOption.tsx index e4d5cebdc..afd4a179e 100644 --- a/app/assets/javascripts/components/NotesOptions/ChangeEditorOption.tsx +++ b/app/assets/javascripts/components/NotesOptions/ChangeEditorOption.tsx @@ -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 = ({ 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({ right: 0, @@ -193,84 +189,29 @@ export const ChangeEditorOption: FunctionComponent = ({ ); 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 ( = ({ }} style={{ ...changeEditorMenuPosition, + maxHeight: changeEditorMenuMaxHeight, position: 'fixed', }} className="sn-dropdown flex flex-col max-h-120 min-w-68 fixed overflow-y-auto" > - diff --git a/app/assets/javascripts/components/NotesOptions/changeEditor/ChangeEditorMenu.tsx b/app/assets/javascripts/components/NotesOptions/changeEditor/ChangeEditorMenu.tsx new file mode 100644 index 000000000..94f58026a --- /dev/null +++ b/app/assets/javascripts/components/NotesOptions/changeEditor/ChangeEditorMenu.tsx @@ -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; +}; + +const getGroupId = (group: EditorMenuGroup) => + group.title.toLowerCase().replace(/\s/, '-'); + +export const ChangeEditorMenu: FunctionComponent = ({ + 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 ( + + {groups + .filter((group) => group.items && group.items.length) + .map((group, index) => { + const groupId = getGroupId(group); + + return ( + +
+ {group.icon && ( + + )} +
{group.title}
+
+ {group.items.map((item) => { + const onClickEditorItem = () => { + selectEditor(item); + }; + + return ( + +
+ {item.name} + {(item.isPremiumFeature || !isEntitledToEditor(item)) && ( + + )} +
+
+ ); + })} +
+ ); + })} +
+ ); +}; diff --git a/app/assets/javascripts/components/NotesOptions/changeEditor/EditorAccordionMenu.tsx b/app/assets/javascripts/components/NotesOptions/changeEditor/EditorAccordionMenu.tsx deleted file mode 100644 index 92c148077..000000000 --- a/app/assets/javascripts/components/NotesOptions/changeEditor/EditorAccordionMenu.tsx +++ /dev/null @@ -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; - 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 ( - -
-

- -

-
-
- {group.items.map((item) => { - const onClickEditorItem = () => { - selectEditor(item); - }; - - return ( - - ); - })} -
-
-
-
-
- ); - })} - - ); -}; diff --git a/app/assets/javascripts/components/NotesOptions/changeEditor/createEditorMenuGroups.ts b/app/assets/javascripts/components/NotesOptions/changeEditor/createEditorMenuGroups.ts index 14911e808..f73ead3a1 100644 --- a/app/assets/javascripts/components/NotesOptions/changeEditor/createEditorMenuGroups.ts +++ b/app/assets/javascripts/components/NotesOptions/changeEditor/createEditorMenuGroups.ts @@ -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'; diff --git a/app/assets/javascripts/components/NotesView.tsx b/app/assets/javascripts/components/NotesView.tsx index eee9337b0..12d129459 100644 --- a/app/assets/javascripts/components/NotesView.tsx +++ b/app/assets/javascripts/components/NotesView.tsx @@ -238,6 +238,7 @@ export const NotesView: FunctionComponent = observer( application={application} closeDisplayOptionsMenu={toggleDisplayOptionsMenu} closeOnBlur={closeDisplayOptMenuOnBlur} + isOpen={showDisplayOptionsMenu} /> )} diff --git a/app/assets/javascripts/components/menu/Menu.tsx b/app/assets/javascripts/components/menu/Menu.tsx index 59b47c9fe..7d6fc9e45 100644 --- a/app/assets/javascripts/components/menu/Menu.tsx +++ b/app/assets/javascripts/components/menu/Menu.tsx @@ -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 = forwardRef( - ( - { children, className = '', style, a11yLabel, closeMenu }: MenuProps, - ref: Ref +export const Menu: FunctionComponent = ({ + children, + className = '', + style, + a11yLabel, + closeMenu, + isOpen, +}: MenuProps) => { + const [currentIndex, setCurrentIndex] = useState(0); + const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]); + + const menuElementRef = useRef(null); + + const handleKeyDown: JSXInternal.KeyboardEventHandler = ( + event ) => { - const [currentIndex, setCurrentIndex] = useState(0); - const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]); + if (!menuItemRefs.current) { + return; + } - const handleKeyDown: JSXInternal.KeyboardEventHandler = ( - 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 = (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; - const isFirstMenuItem = - index === - array.findIndex((child) => (child as VNode).type === MenuItem); - - const hasMultipleItems = Array.isArray(_child.props.children) - ? Array.from(_child.props.children as ComponentChild[]).some( - (child) => (child as VNode).type === MenuItem - ) - : false; - - const items = hasMultipleItems - ? [...(_child.props.children as ComponentChild[])] - : [_child]; - - return items.map((child) => { - return ( - - {child} - - ); + const pushRefToArray: RefCallback = (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 ( - - {Array.isArray(children) ? children.map(mapMenuItems) : null} - - ); - } -); + const mapMenuItems = ( + child: ComponentChild, + index: number, + array: ComponentChild[] + ) => { + if (!child) return; + + const _child = child as VNode; + const isFirstMenuItem = + index === + array.findIndex((child) => (child as VNode).type === MenuItem); + + const hasMultipleItems = Array.isArray(_child.props.children) + ? Array.from(_child.props.children as ComponentChild[]).some( + (child) => (child as VNode).type === MenuItem + ) + : false; + + const items = hasMultipleItems + ? [...(_child.props.children as ComponentChild[])] + : [_child]; + + return items.map((child) => { + return ( + + {child} + + ); + }); + }; + + return ( + + {Array.isArray(children) ? children.map(mapMenuItems) : null} + + ); +}; diff --git a/app/assets/javascripts/services/ioService.ts b/app/assets/javascripts/services/ioService.ts index c4885ec37..b9415c62c 100644 --- a/app/assets/javascripts/services/ioService.ts +++ b/app/assets/javascripts/services/ioService.ts @@ -6,6 +6,8 @@ export enum KeyboardKey { Down = 'ArrowDown', Enter = 'Enter', Escape = 'Escape', + Home = 'Home', + End = 'End', } export enum KeyboardModifier { diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index 7c50835ef..521bf1432 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -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; +}