Skip to content

Commit

Permalink
feat(portal): add ARIA & focusing with key event
Browse files Browse the repository at this point in the history
  • Loading branch information
kyechan99 committed Mar 29, 2024
1 parent 5df5669 commit c6bd799
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 52 deletions.
2 changes: 1 addition & 1 deletion src/components/Dialog/DialogContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const DialogContent = React.forwardRef<HTMLDivElement, ModalProps>(
createPortal(
<>
<ModalBG onClick={close} className="bmates-modal-bg" />
<Modal ref={ref} maxWidth={maxWidth} {...props}>
<Modal ref={ref} role="dialog" aria-modal="true" maxWidth={maxWidth} {...props}>
{props.children}
{!hideClose && (
<ExitButton onClick={closeBtnHandler}>
Expand Down
1 change: 1 addition & 0 deletions src/components/Dialog/DialogToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const DialogToggle = React.forwardRef<HTMLButtonElement, ComponentPropsWi
return (
<Comp
ref={ref}
aria-haspopup="true"
onClick={composeEventHandlers(onClick, () => {
setShowModal(true);
})}
Expand Down
2 changes: 1 addition & 1 deletion src/components/Dropdown/DropdownContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface ModalProps extends React.ComponentPropsWithoutRef<'div'> {
*/
export const DropdownContent = React.forwardRef<HTMLDivElement, ModalProps>(({ width, children, ...props }, ref) => {
return (
<PortalContent width={width} ref={ref} {...props}>
<PortalContent width={width} ref={ref} role="group" {...props}>
<DropdownListBox>{children}</DropdownListBox>
</PortalContent>
);
Expand Down
5 changes: 4 additions & 1 deletion src/components/Dropdown/DropdownItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ export const DropdownItem = React.forwardRef<HTMLLIElement, DropdownItemProps>(
<DropdownItemStyled
ref={ref}
tabIndex={0}
role={'menuitem'}
disabled={disabled}
onClick={composeEventHandlers(props.onClick, onClickHandler)}
data-focus-enabled="true"
{...props}
></DropdownItemStyled>
);
Expand All @@ -47,7 +49,8 @@ const DropdownItemStyled = styled.li<{ disabled: boolean }>`
opacity: 0.5;
`
: css`
&:hover {
&:hover,
&:focus {
background-color: var(--gray-100);
}
`}
Expand Down
1 change: 1 addition & 0 deletions src/components/Dropdown/DropdownToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const DropdownToggle = React.forwardRef<HTMLButtonElement, ComponentProps
return (
<Comp
ref={composeRefs(compRef, ref)}
aria-haspopup="true"
onClick={composeEventHandlers(onClick, () => {
if (compRef.current) {
const rect = compRef.current;
Expand Down
121 changes: 121 additions & 0 deletions src/components/Portal/Portal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import React, { useState } from 'react';

import { usePortal } from '@/components/Portal/usePortal';
import { composeEventHandlers } from '@/libs/event';
import { composeRefs } from '@/libs/ref';
import { PositionType } from '@/types/position';

interface ModalContentProps extends React.ComponentProps<'div'> {
ref: React.ForwardedRef<HTMLDivElement>;
width?: React.CSSProperties['width'];
disabledBG?: boolean;
}

const Portal = ({ children, ref, width, onKeyDown, ...props }: ModalContentProps) => {
const portalRef = React.useRef<HTMLDivElement>(null);
const { showModal, toggleElement, reorgPos } = usePortal({ portalRef });
const [items, setItems] = useState<HTMLElement[]>([]);

const ACTIONS: Record<string, (e: React.KeyboardEvent<HTMLInputElement>) => void> = {
ArrowDown: () => focus('next'),
ArrowUp: () => focus('prev'),
Tab: e => e.preventDefault(),
Enter: () => select(),
};

const handleOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (Object.keys(ACTIONS).includes(e.key)) {
const handler = ACTIONS[e.key];

if (handler) {
e.preventDefault();
handler(e);
}
}
};

const focus = (key: 'next' | 'prev'): void => {
if (!items.length) return;

const { activeElement } = document;

if (activeElement instanceof HTMLElement) {
let idx = items.indexOf(activeElement);
if (key === 'next') idx = (idx + 1) % items.length;
else idx = (idx - 1 + items.length) % items.length;

const nextElement = items[idx] as HTMLElement;
if (nextElement) nextElement.focus();
}
};

const select = () => {
const { activeElement } = document;

const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
});

activeElement?.dispatchEvent(clickEvent);
};

const handleOpen = React.useCallback(() => {
if (portalRef.current) {
portalRef.current.focus();
setItems(
Array.from(portalRef.current.querySelectorAll<HTMLElement>('[data-focus-enabled="true"]')).filter(e => {
return e.getAttribute('disabled') == null;
}),
);
}
}, []);

React.useEffect(() => {
if (showModal && portalRef.current) handleOpen();
}, [handleOpen, showModal]);

return (
<PortalStyled
ref={composeRefs(portalRef, ref)}
width={width || toggleElement?.getBoundingClientRect().width}
position={reorgPos}
onKeyDown={composeEventHandlers(handleOnKeyDown, onKeyDown)}
tabIndex={-1}
{...props}
>
{children}
</PortalStyled>
);
};
export default Portal;

const enterAnimation = keyframes`
0% { opacity: 0; }
100% { opacity: 1; }
`;

const PortalStyled = styled.div<{ width?: React.CSSProperties['width']; position: PositionType }>`
display: grid;
min-width: max-content;
${({ width }) => width && `width: ${typeof width === 'string' ? width : `${width}px`};`}
padding: 0.25rem;
border-radius: 0.25rem;
position: fixed;
top: 0px;
left: 0px;
transform: ${({ position }) => `translate(${position.x}px, ${position.y}px)`};
background-color: white;
pointer-events: auto;
z-index: 50;
animation-name: ${enterAnimation};
animation-duration: 0.15s;
border: 1px solid var(--gray-300);
box-shadow: 0px 2px 2px 0px var(--gray-300);
&:focus {
outline: none;
}
`;
48 changes: 7 additions & 41 deletions src/components/Portal/PortalContent.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';
import { createPortal } from 'react-dom';

import { usePortal } from '@/components/Portal/usePortal';
import useContext from '@/hooks/useContext';
import { composeRefs } from '@/libs/ref';
import { PositionType } from '@/types/position';

import Portal from './Portal';
import { PortalContext } from './PortalContext';

interface ModalContentProps extends React.ComponentProps<'div'> {
Expand All @@ -16,10 +13,8 @@ interface ModalContentProps extends React.ComponentProps<'div'> {
disabledBG?: boolean;
}

export const PortalContent = ({ children, ref, width, disabledBG = false, ...props }: ModalContentProps) => {
const { showModal, setShowModal, toggleElement } = useContext(PortalContext)!;
const modalRef = React.useRef<HTMLDivElement>(null);
const { reorgPos } = usePortal({ modalRef });
export const PortalContent = ({ children, ref, width, disabledBG = false, onKeyDown, ...props }: ModalContentProps) => {
const { showModal, setShowModal } = useContext(PortalContext)!;

const close = () => {
setShowModal(false);
Expand All @@ -30,50 +25,21 @@ export const PortalContent = ({ children, ref, width, disabledBG = false, ...pro
{showModal &&
createPortal(
<>
{!disabledBG && <ModalBG onClick={close} id="bmates-portal-bg" className="bmates-portal-bg" />}
<Modal
ref={composeRefs(modalRef, ref)}
width={width || toggleElement?.getBoundingClientRect().width}
position={reorgPos}
{...props}
>
{!disabledBG && <PortalBG id="bmates-portal-bg" className="bmates-portal-bg" onClick={close} />}
<Portal ref={ref} width={width} onKeyDown={onKeyDown} {...props}>
{children}
</Modal>
</Portal>
</>,
document.body,
)}
</>
);
};

const enter = keyframes`
0% { opacity: 0; }
100% { opacity: 1; }
`;

const ModalBG = styled.div`
const PortalBG = styled.div`
position: fixed;
background-color: transparent;
pointer-events: auto;
z-index: 50;
inset: 0;
`;

const Modal = styled.div<{ width?: React.CSSProperties['width']; position: PositionType }>`
display: grid;
min-width: max-content;
${({ width }) => width && `width: ${typeof width === 'string' ? width : `${width}px`};`}
padding: 0.25rem;
border-radius: 0.25rem;
position: fixed;
top: 0px;
left: 0px;
transform: ${({ position }) => `translate(${position.x}px, ${position.y}px)`};
background-color: white;
pointer-events: auto;
z-index: 50;
animation-name: ${enter};
animation-duration: 0.15s;
border: 1px solid var(--gray-300);
box-shadow: 0px 2px 2px 0px var(--gray-300);
`;
7 changes: 6 additions & 1 deletion src/components/Portal/PortalProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ export interface PortalProviderProps extends React.PropsWithChildren, PortalType
}

export const PortalProvider = ({ align, space, enableScroll, children }: PortalProviderProps) => {
const [showModal, setShowModal] = useModal(enableScroll);
const [showModal, _setShowModal] = useModal(enableScroll);
const [toggleElement, setToggleElment] = React.useState<HTMLElement>();

const setShowModal = (value: boolean) => {
_setShowModal(value);
if (!value) window.setTimeout(() => toggleElement?.focus());
};

return (
<PortalContext.Provider
value={{
Expand Down
19 changes: 12 additions & 7 deletions src/components/Portal/usePortal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,26 @@ import React from 'react';
import { PortalContext } from '@/components/Portal/PortalContext';
import { PositionType } from '@/types/position';

export const usePortal = ({ modalRef }: { modalRef: React.RefObject<HTMLDivElement> }) => {
export const usePortal = ({ portalRef }: { portalRef: React.RefObject<HTMLDivElement> }) => {
const {
showModal,
align,
space = 0,
setShowModal: setModal,
toggleElement,
setShowModal,
setToggleElment,
align,
space = 0,
} = React.useContext(PortalContext)!;
const [reorgPos, setReorgPos] = React.useState<PositionType>({ x: 0, y: 0 });

const setShowModal = (value: boolean) => {
setModal(value);
if (!value) window.setTimeout(() => toggleElement?.focus());
};

React.useEffect(() => {
const adjustmentPos = () => {
if (modalRef.current && toggleElement && showModal) {
const rect = modalRef.current;
if (portalRef.current && toggleElement && showModal) {
const rect = portalRef.current;
const toggleRect = toggleElement.getBoundingClientRect();

const isOverflowing = rect.offsetHeight + toggleRect.bottom + space >= window.innerHeight;
Expand Down Expand Up @@ -54,7 +59,7 @@ export const usePortal = ({ modalRef }: { modalRef: React.RefObject<HTMLDivEleme
return () => {
window.removeEventListener('resize', adjustmentPos);
};
}, [align, showModal, toggleElement, modalRef, space]);
}, [align, showModal, toggleElement, portalRef, space]);

return { showModal, align, toggleElement, setShowModal, setToggleElment, reorgPos };
};

0 comments on commit c6bd799

Please sign in to comment.