Skip to content

Commit

Permalink
feat(ui): add navigate through items feature with keyboard
Browse files Browse the repository at this point in the history
  • Loading branch information
ZeynalliZeynal committed Jan 18, 2025
1 parent 94a1c2f commit d09ebc2
Show file tree
Hide file tree
Showing 6 changed files with 92 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ export default function DropdownMenuDefault() {
<Popper>
<PopperTrigger>Open</PopperTrigger>
<PopperContent>
<PopperItem onClick={() => console.log(false)}>One</PopperItem>
<PopperItem>One</PopperItem>
<PopperItem>Two</PopperItem>
<PopperItem>One</PopperItem>
<PopperItem>Two</PopperItem>
<PopperItem>One</PopperItem>
<PopperItem>Two</PopperItem>
</PopperContent>
</Popper>
Expand Down
56 changes: 53 additions & 3 deletions components/ui/primitives/popper/popper-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,66 @@ import { useOutsideClick } from '@/hooks/use-ui';
import { cn } from '@/utils/lib';
import React from 'react';
import ReactFocusLock from 'react-focus-lock';
import { POPPER_ITEM_SELECTOR } from '../selectors';

export function PopperContent(props: PopperContentProps) {
const { children, className, ...etc } = props;
const { isOpen, popperStyle, closePopper, id } = usePopper();
const {
isOpen,
popperStyle,
closePopper,
activeTrigger,
highlightedIndex,
setHighlightedIndex,
highlight,
id,
} = usePopper();

const ref = useOutsideClick({ action: closePopper });

function handleKeyDown(event: React.KeyboardEvent) {
if (!ref.current) return;
if (event.key === 'Escape') {
closePopper();
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
const direction: 'next' | 'previous' =
event.code === 'ArrowUp' ? 'previous' : 'next';

const menuItems = Array.from(
ref.current.querySelectorAll(POPPER_ITEM_SELECTOR),
);

let nextIndex;
switch (direction) {
case 'next':
nextIndex =
highlightedIndex === undefined ||
highlightedIndex === menuItems.length - 1
? menuItems.indexOf(menuItems[menuItems.length - 1])
: highlightedIndex + 1;
break;
default:
nextIndex =
highlightedIndex === undefined || highlightedIndex === 0
? 0
: highlightedIndex - 1;
break;
}
console.log(highlightedIndex);
setHighlightedIndex(nextIndex);
highlight(menuItems[nextIndex] as HTMLElement);
}
}

return createPortal(
<AnimatePresence>
<AnimatePresence onExitComplete={() => activeTrigger?.focus()}>
{isOpen && popperStyle && (
<ReactFocusLock>
<ReactFocusLock
disabled={!isOpen}
onDeactivation={() => activeTrigger?.focus()}
>
<motion.div
animate={{
opacity: 1,
Expand Down Expand Up @@ -49,6 +98,7 @@ export function PopperContent(props: PopperContentProps) {
'bg-background-100 p-2 rounded-xl border w-48 mt-2 focus:outline-0',
className,
)}
onKeyDown={handleKeyDown}
{...etc}
>
{children}
Expand Down
25 changes: 20 additions & 5 deletions components/ui/primitives/popper/popper-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import React, { CSSProperties, useState } from 'react';
import { PopperContextProps } from './popper.types';
import { useRestrict } from '@/hooks/use-ui';
import { POPPER_CONTENT_SELECTOR } from '@/components/ui/primitives/selectors';
import {
POPPER_CONTENT_SELECTOR,
POPPER_ITEM_SELECTOR,
} from '@/components/ui/primitives/selectors';

const PopperContext = React.createContext<PopperContextProps | null>(null);

Expand All @@ -17,7 +20,9 @@ export function usePopper() {
export function PopperProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = React.useState<boolean>(false);
const [popperStyle, setPopperStyle] = useState<CSSProperties>({});
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
const [highlightedIndex, setHighlightedIndex] = React.useState<
number | undefined
>(undefined);
const [highlightedItem, setHighlightedItem] =
React.useState<HTMLElement | null>(null);

Expand All @@ -29,8 +34,15 @@ export function PopperProvider({ children }: { children: React.ReactNode }) {

function highlight(element: HTMLElement | null) {
if (element) {
const rootElement = element.closest(
POPPER_CONTENT_SELECTOR,
) as HTMLElement;
const items = Array.from(
rootElement.querySelectorAll(POPPER_ITEM_SELECTOR),
);
setHighlightedItem(element);
element?.focus();
setHighlightedIndex(items.indexOf(element));
} else {
setHighlightedItem(null);
(document.querySelector(POPPER_CONTENT_SELECTOR) as HTMLElement).focus();
Expand All @@ -46,17 +58,17 @@ export function PopperProvider({ children }: { children: React.ReactNode }) {
setIsOpen((prevState) => !prevState);

activeTrigger.current = event.currentTarget;

(document.querySelector(POPPER_CONTENT_SELECTOR) as HTMLElement)?.focus();
}

function closePopper() {
setIsOpen(false);
activeTrigger.current?.focus();
setIsOpen(false);
setHighlightedItem(null);
setHighlightedIndex(undefined);
}

useRestrict({ condition: isOpen });

return (
<PopperContext.Provider
value={{
Expand All @@ -66,7 +78,10 @@ export function PopperProvider({ children }: { children: React.ReactNode }) {
popperStyle,
id,
highlightedItem,
highlightedIndex,
highlight,
setHighlightedIndex,
activeTrigger: activeTrigger.current,
}}
>
{children}
Expand Down
11 changes: 9 additions & 2 deletions components/ui/primitives/popper/popper-trigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@
import { PopperTriggerProps } from '@/components/ui/primitives/popper/popper.types';
import { usePopper } from '@/components/ui/primitives/popper/popper-context';
import { Button } from '@/components/ui/button';
import { chain } from '@/utils/chain';

export function PopperTrigger(props: PopperTriggerProps) {
const { children } = props;
const { children, onMouseDown, onClick, ...etc } = props;
const { openPopper, id } = usePopper();

function handleMouseDown(event: React.MouseEvent<HTMLButtonElement>) {
openPopper(event);
}

return (
<Button aria-controls={id} size="sm" onMouseDown={handleMouseDown}>
<Button
aria-controls={id}
size="sm"
onMouseDown={chain(handleMouseDown, onMouseDown)}
onClick={chain(handleMouseDown, onClick)}
{...etc}
>
{children}
</Button>
);
Expand Down
5 changes: 4 additions & 1 deletion components/ui/primitives/popper/popper.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export interface PopperContextProps {
popperStyle: CSSProperties;
id: string;
highlightedItem: HTMLElement | null;
activeTrigger: HTMLElement | null;
highlightedIndex?: number;
setHighlightedIndex(value?: number): void;
highlight(element: HTMLElement | null): void;
openPopper(event: React.MouseEvent<HTMLElement>): void;
closePopper(): void;
Expand All @@ -14,7 +17,7 @@ export interface PopperProps {
children: React.ReactNode;
}

export interface PopperTriggerProps {
export interface PopperTriggerProps extends ComponentProps<'button'> {
children: React.ReactNode;
}

Expand Down
1 change: 1 addition & 0 deletions components/ui/primitives/selectors.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const POPPER_CONTENT_SELECTOR = '[data-popper-content]';
export const POPPER_ITEM_SELECTOR = '[data-popper-item]';

0 comments on commit d09ebc2

Please sign in to comment.