diff --git a/package-lock.json b/package-lock.json index 46263feba..3fe70e210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "reablocks", - "version": "9.2.1", + "version": "9.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reablocks", - "version": "9.2.1", + "version": "9.2.2", "license": "Apache-2.0", "dependencies": { "@floating-ui/react": "^0.27.16", diff --git a/src/form/Textarea/Textarea.tsx b/src/form/Textarea/Textarea.tsx index 98bf7d7f5..bf1760184 100644 --- a/src/form/Textarea/Textarea.tsx +++ b/src/form/Textarea/Textarea.tsx @@ -79,7 +79,7 @@ export const Textarea = forwardRef( const textareaRef = useRef(null); useImperativeHandle(inputRef, () => ({ - textareaRef, + inputRef: textareaRef, containerRef, blur: () => textareaRef.current?.blur(), focus: () => textareaRef.current?.focus() diff --git a/src/layers/Menu/Menu.story.tsx b/src/layers/Menu/Menu.story.tsx index 8de1c8971..e8c23483b 100644 --- a/src/layers/Menu/Menu.story.tsx +++ b/src/layers/Menu/Menu.story.tsx @@ -67,27 +67,29 @@ export const Nested = () => {
My Menu
-
-
+ + null}> 1 -
+ -
- 2.1 -
-
- 2.2 -
-
- 2.3 -
-
- 2.4 -
+ + null}> + 2.1 + + null}> + 2.2 + + null}> + 2.3 + + null}> + 2.4 + +
{ style={itemStyle} menuStyle={{ background: 'var(--slate-500)', marginLeft: 4 }} > -
- 3.1 -
- -
- 3.2.1 -
-
- 3.2.2 -
-
- 3.2.3 -
-
- 3.2.4 -
-
-
- 3.3 -
- { - nestedMenuRef.current?.close(); - }} - > - Close - + + null}> + 3.1 + + + + null} + > + 3.2.1 + + null} + > + 3.2.2 + + null} + > + 3.2.3 + + null} + > + 3.2.4 + + + + null}> + 3.3 + + { + nestedMenuRef.current?.close(); + }} + > + Close + +
-
+ null}> 4 -
-
+ +
); diff --git a/src/layers/Menu/NestedMenu.tsx b/src/layers/Menu/NestedMenu.tsx index 7eec6aa2f..40779f97d 100644 --- a/src/layers/Menu/NestedMenu.tsx +++ b/src/layers/Menu/NestedMenu.tsx @@ -1,6 +1,9 @@ +import { ListItem } from '@/layout'; import React, { forwardRef, Fragment, + KeyboardEvent, + ReactNode, useCallback, useImperativeHandle, useRef, @@ -23,7 +26,7 @@ export interface NestedMenuProps { /** * Menu contents. */ - children: any; + children: ReactNode | ((args: NestedMenuRef) => ReactNode); /** * Label element for the menu item. @@ -150,7 +153,7 @@ export const NestedMenu = forwardRef( }, leaveDelay); }, [leaveDelay]); - const onMouseEnterMenu = useCallback(event => { + const onMouseEnterMenu = useCallback(() => { clearTimeout(enterTimeoutRef.current); clearTimeout(leaveTimeoutRef.current); menuEntered.current = true; @@ -179,6 +182,13 @@ export const NestedMenu = forwardRef( [onClose] ); + const onKeyDown = useCallback((e: KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'ArrowLeft' || e.key === 'Escape') { + setActive(false); + } + }, []); + /** * Expose the close ability to the outside */ @@ -190,7 +200,7 @@ export const NestedMenu = forwardRef( return ( -
( onMouseLeave={onMouseLeaveItem} > {label} -
+ ( onMouseLeave={onMouseLeaveMenu} onClose={onNestedMenuClose} > - {children} + {() => ( +
+ {typeof children === 'function' + ? children({ close: () => setActive(false) }) + : children} +
+ )}
); diff --git a/src/layout/List/List.tsx b/src/layout/List/List.tsx index 203a67fb6..022614496 100644 --- a/src/layout/List/List.tsx +++ b/src/layout/List/List.tsx @@ -1,4 +1,13 @@ -import React, { FC, forwardRef, InputHTMLAttributes, LegacyRef } from 'react'; +import React, { + Children, + cloneElement, + FC, + forwardRef, + InputHTMLAttributes, + isValidElement, + LegacyRef, + useRef +} from 'react'; import { twMerge } from 'tailwind-merge'; import { useComponentTheme } from '@/utils'; import { ListTheme } from './ListTheme'; @@ -22,14 +31,55 @@ export const List: FC = forwardRef< ListProps >(({ className, children, theme: customTheme, ...rest }, ref) => { const theme: ListTheme = useComponentTheme('list', customTheme); + const containerRef = useRef(null); + + const handleKeyDown = (event: React.KeyboardEvent) => { + // Remove the first invisible item when navigating with keyboard is started + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + containerRef.current + ?.querySelector('[data-first-item="true"]') + ?.remove(); + } + const focusableItems = Array.from( + containerRef.current?.querySelectorAll( + '[tabindex]:not(.disabled)' + ) || [] + ); + const currentIndex = focusableItems.indexOf( + document.activeElement as HTMLElement + ); + + if (event.key === 'ArrowDown') { + event.preventDefault(); + const nextIndex = (currentIndex + 1) % focusableItems.length; + focusableItems[nextIndex]?.focus(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + const prevIndex = + (currentIndex - 1 + focusableItems.length) % focusableItems.length; + focusableItems[prevIndex]?.focus(); + } else if (event.key === 'Enter') { + event.preventDefault(); + (document.activeElement as HTMLElement)?.click(); + } + }; + + const focusableChildren = Children.map(children, child => + isValidElement(child) ? cloneElement(child, { tabIndex: 0 }) : child + ); + return ( -
- {children} +
+ {/* First invisible item which takes focus for correct keyboard navigation*/} +
+
+ {focusableChildren} +
); }); diff --git a/src/layout/List/ListItem/ListItem.tsx b/src/layout/List/ListItem/ListItem.tsx index 25b0d7256..06de1dc09 100644 --- a/src/layout/List/ListItem/ListItem.tsx +++ b/src/layout/List/ListItem/ListItem.tsx @@ -89,12 +89,15 @@ export const ListItem: FC = forwardRef< onClick={e => !disabled && onClick?.(e)} className={cn( theme.listItem.base, - dense && theme.listItem.dense.base, - disabled && theme.listItem.disabled, - active && theme.listItem.active, - onClick && !disabled && theme.listItem.clickable, - disablePadding && theme.listItem.disablePadding, - disableGutters && theme.listItem.disableGutters, + { + disabled: disabled, + [theme.listItem.disabled]: disabled, + [theme.listItem.dense.base]: dense, + [theme.listItem.active]: active, + [theme.listItem.clickable]: onClick && !disabled, + [theme.listItem.disablePadding]: disablePadding, + [theme.listItem.disableGutters]: disableGutters + }, className )} >