Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/form/Textarea/Textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const Textarea = forwardRef<TextAreaRef, TextareaProps>(
const textareaRef = useRef<HTMLTextAreaElement | null>(null);

useImperativeHandle(inputRef, () => ({
textareaRef,
inputRef: textareaRef,
containerRef,
blur: () => textareaRef.current?.blur(),
focus: () => textareaRef.current?.focus()
Expand Down
120 changes: 71 additions & 49 deletions src/layers/Menu/Menu.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,70 +67,92 @@ export const Nested = () => {
</Button>
<MenuComponent style={{ background: 'var(--slate-500)' }}>
<h5 style={{ margin: 4 }}>My Menu</h5>
<div role="list">
<div role="listitem" style={itemStyle}>
<List>
<ListItem role="listitem" style={itemStyle} onClick={() => null}>
1
</div>
</ListItem>
<NestedMenu
label="2"
style={itemStyle}
menuStyle={{ background: 'var(--slate-500)', marginLeft: 4 }}
>
<div role="listitem" style={itemStyle}>
2.1
</div>
<div role="listitem" style={itemStyle}>
2.2
</div>
<div role="listitem" style={itemStyle}>
2.3
</div>
<div role="listitem" style={itemStyle}>
2.4
</div>
<List>
<ListItem role="listitem" style={itemStyle} onClick={() => null}>
2.1
</ListItem>
<ListItem role="listitem" style={itemStyle} onClick={() => null}>
2.2
</ListItem>
<ListItem role="listitem" style={itemStyle} onClick={() => null}>
2.3
</ListItem>
<ListItem role="listitem" style={itemStyle} onClick={() => null}>
2.4
</ListItem>
</List>
</NestedMenu>
<NestedMenu
ref={nestedMenuRef}
label="3"
style={itemStyle}
menuStyle={{ background: 'var(--slate-500)', marginLeft: 4 }}
>
<div role="listitem" style={itemStyle}>
3.1
</div>
<NestedMenu
label="3.2"
style={itemStyle}
menuStyle={{ background: 'var(--slate-500)', marginLeft: 4 }}
>
<div role="listitem" style={itemStyle}>
3.2.1
</div>
<div role="listitem" style={itemStyle}>
3.2.2
</div>
<div role="listitem" style={itemStyle}>
3.2.3
</div>
<div role="listitem" style={itemStyle}>
3.2.4
</div>
</NestedMenu>
<div role="listitem" style={itemStyle}>
3.3
</div>
<ListItem
onClick={() => {
nestedMenuRef.current?.close();
}}
>
Close
</ListItem>
<List>
<ListItem role="listitem" style={itemStyle} onClick={() => null}>
3.1
</ListItem>
<NestedMenu
label="3.2"
style={itemStyle}
menuStyle={{ background: 'var(--slate-500)', marginLeft: 4 }}
>
<List>
<ListItem
role="listitem"
style={itemStyle}
onClick={() => null}
>
3.2.1
</ListItem>
<ListItem
role="listitem"
style={itemStyle}
onClick={() => null}
>
3.2.2
</ListItem>
<ListItem
role="listitem"
style={itemStyle}
onClick={() => null}
>
3.2.3
</ListItem>
<ListItem
role="listitem"
style={itemStyle}
onClick={() => null}
>
3.2.4
</ListItem>
</List>
</NestedMenu>
<ListItem role="listitem" style={itemStyle} onClick={() => null}>
3.3
</ListItem>
<ListItem
onClick={() => {
nestedMenuRef.current?.close();
}}
>
Close
</ListItem>
</List>
</NestedMenu>
<div role="listitem" style={itemStyle}>
<ListItem role="listitem" style={itemStyle} onClick={() => null}>
4
</div>
</div>
</ListItem>
</List>
</MenuComponent>
</Fragment>
);
Expand Down
26 changes: 21 additions & 5 deletions src/layers/Menu/NestedMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ListItem } from '@/layout';
import React, {
forwardRef,
Fragment,
KeyboardEvent,
ReactNode,
useCallback,
useImperativeHandle,
useRef,
Expand All @@ -23,7 +26,7 @@ export interface NestedMenuProps {
/**
* Menu contents.
*/
children: any;
children: ReactNode | ((args: NestedMenuRef) => ReactNode);

/**
* Label element for the menu item.
Expand Down Expand Up @@ -150,7 +153,7 @@ export const NestedMenu = forwardRef<NestedMenuRef, NestedMenuProps>(
}, leaveDelay);
}, [leaveDelay]);

const onMouseEnterMenu = useCallback(event => {
const onMouseEnterMenu = useCallback(() => {
clearTimeout(enterTimeoutRef.current);
clearTimeout(leaveTimeoutRef.current);
menuEntered.current = true;
Expand Down Expand Up @@ -179,6 +182,13 @@ export const NestedMenu = forwardRef<NestedMenuRef, NestedMenuProps>(
[onClose]
);

const onKeyDown = useCallback((e: KeyboardEvent) => {
e.stopPropagation();
if (e.key === 'ArrowLeft' || e.key === 'Escape') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't escape close the menu all together? I know the ExitListener often is handling this. Just calling this out.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is closing, but all levels together. But we need to give user ability get back to previous level

setActive(false);
}
}, []);

/**
* Expose the close ability to the outside
*/
Expand All @@ -190,7 +200,7 @@ export const NestedMenu = forwardRef<NestedMenuRef, NestedMenuProps>(

return (
<Fragment>
<div
<ListItem
className={classNames(className, { [activeClassName]: active })}
style={style}
ref={itemRef}
Expand All @@ -199,7 +209,7 @@ export const NestedMenu = forwardRef<NestedMenuRef, NestedMenuProps>(
onMouseLeave={onMouseLeaveItem}
>
{label}
</div>
</ListItem>
<Menu
className={menuClassName}
autofocus={autofocus}
Expand All @@ -216,7 +226,13 @@ export const NestedMenu = forwardRef<NestedMenuRef, NestedMenuProps>(
onMouseLeave={onMouseLeaveMenu}
onClose={onNestedMenuClose}
>
{children}
{() => (
<div onKeyDown={onKeyDown}>
{typeof children === 'function'
? children({ close: () => setActive(false) })
: children}
</div>
)}
</Menu>
</Fragment>
);
Expand Down
66 changes: 58 additions & 8 deletions src/layout/List/List.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,14 +31,55 @@ export const List: FC<ListProps & ListRef> = forwardRef<
ListProps
>(({ className, children, theme: customTheme, ...rest }, ref) => {
const theme: ListTheme = useComponentTheme('list', customTheme);
const containerRef = useRef<HTMLDivElement>(null);

const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im not sure I agree this should be in List ( or at least not in the base list component ).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not in the List? It is have the same functionality as menu and user should have ability to navigate it in the same way/

Copy link
Contributor Author

@evgenoid evgenoid Oct 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, it works only for items with tab index (simplly say which have onClick callback)

// Remove the first invisible item when navigating with keyboard is started
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
containerRef.current
?.querySelector<HTMLElement>('[data-first-item="true"]')
?.remove();
}
const focusableItems = Array.from(
containerRef.current?.querySelectorAll<HTMLElement>(
'[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 (
<div
{...rest}
ref={ref}
role="list"
className={twMerge(theme.base, className)}
>
{children}
<div ref={containerRef} onKeyDown={handleKeyDown}>
{/* First invisible item which takes focus for correct keyboard navigation*/}
<div data-first-item="true" tabIndex={0} />
<div
{...rest}
ref={ref}
role="list"
className={twMerge(theme.base, className)}
>
{focusableChildren}
</div>
</div>
);
});
15 changes: 9 additions & 6 deletions src/layout/List/ListItem/ListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,15 @@ export const ListItem: FC<ListItemProps & ListItemRef> = 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
)}
>
Expand Down
Loading