mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-19 16:21:13 +01:00
Focus traps for dialog and dropdown
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import classnames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Portal } from './Portal';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
portalName: string;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
zIndex?: keyof typeof zIndexes;
|
||||
}
|
||||
|
||||
@@ -19,22 +20,24 @@ const zIndexes: Record<number, string> = {
|
||||
50: 'z-50',
|
||||
};
|
||||
|
||||
export function Overlay({ zIndex = 30, open, children, onClick, portalName }: Props) {
|
||||
export function Overlay({ zIndex = 30, open, onClose, portalName, children }: Props) {
|
||||
return (
|
||||
<Portal name={portalName}>
|
||||
{open && (
|
||||
<motion.div
|
||||
className={classnames('fixed inset-0', zIndexes[zIndex])}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={onClick}
|
||||
className="absolute inset-0 bg-gray-600/60 dark:bg-black/50"
|
||||
/>
|
||||
{children}
|
||||
</motion.div>
|
||||
<FocusTrap>
|
||||
<motion.div
|
||||
className={classnames('fixed inset-0', zIndexes[zIndex])}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-gray-600/60 dark:bg-black/50"
|
||||
/>
|
||||
{children}
|
||||
</motion.div>
|
||||
</FocusTrap>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
className={classnames(
|
||||
className,
|
||||
'bg-gray-50 max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ',
|
||||
'dark:bg-gray-100 rounded-md overflow-hidden border border-highlight',
|
||||
'dark:bg-gray-100 rounded-md border border-highlight',
|
||||
'shadow shadow-gray-100 dark:shadow-gray-0 relative',
|
||||
)}
|
||||
>
|
||||
@@ -103,6 +103,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
|
||||
icon="triangleDown"
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
iconSize="sm"
|
||||
/>
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
|
||||
@@ -108,12 +108,13 @@ export default function Workspace() {
|
||||
<WorkspaceHeader className="pointer-events-none" />
|
||||
</HeaderSize>
|
||||
{floating ? (
|
||||
<Overlay open={!sidebar.hidden} portalName="sidebar" onClick={sidebar.hide}>
|
||||
<Overlay open={!sidebar.hidden} portalName="sidebar" onClose={sidebar.hide}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={classnames(
|
||||
'absolute top-0 left-0 bottom-0 bg-gray-100 border-r border-highlight w-[14rem]',
|
||||
'grid grid-rows-[auto_1fr]',
|
||||
)}
|
||||
>
|
||||
<HeaderSize className="border-transparent">
|
||||
|
||||
@@ -6,12 +6,12 @@ import { Icon } from './Icon';
|
||||
|
||||
const colorStyles = {
|
||||
custom: '',
|
||||
default: 'text-gray-700 enabled:hover:bg-gray-700/10 enabled:hover:text-gray-1000',
|
||||
gray: 'text-gray-800 bg-highlight enabled:hover:bg-gray-500/20 enabled:hover:text-gray-1000',
|
||||
primary: 'bg-blue-400 text-white hover:bg-blue-500',
|
||||
secondary: 'bg-violet-400 text-white hover:bg-violet-500',
|
||||
warning: 'bg-orange-400 text-white hover:bg-orange-500',
|
||||
danger: 'bg-red-400 text-white hover:bg-red-500',
|
||||
default: 'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000',
|
||||
gray: 'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000',
|
||||
primary: 'bg-blue-400 text-white enabled:hocus:bg-blue-500',
|
||||
secondary: 'bg-violet-400 text-white enabled:hocus:bg-violet-500',
|
||||
warning: 'bg-orange-400 text-white enabled:hocus:bg-orange-500',
|
||||
danger: 'bg-red-400 text-white enabled:hocus:bg-red-500',
|
||||
};
|
||||
|
||||
export type ButtonProps = HTMLAttributes<HTMLElement> & {
|
||||
@@ -44,7 +44,8 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
|
||||
classnames(
|
||||
className,
|
||||
'outline-none whitespace-nowrap',
|
||||
'border border-transparent focus-visible:border-blue-300',
|
||||
// 'border border-transparent focus-visible:border-focus',
|
||||
'focus-visible:ring ring-blue-300',
|
||||
'rounded-md flex items-center',
|
||||
colorStyles[color || 'default'],
|
||||
justify === 'start' && 'justify-start',
|
||||
|
||||
@@ -34,7 +34,7 @@ export function Dialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<Overlay open={open} onClick={onClose} portalName="dialog">
|
||||
<Overlay open={open} onClose={onClose} portalName="dialog">
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div
|
||||
role="dialog"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import classnames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
|
||||
import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { useKeyPressEvent, useMount } from 'react-use';
|
||||
import { Portal } from '../Portal';
|
||||
import { Separator } from './Separator';
|
||||
import { VStack } from './Stacks';
|
||||
@@ -82,6 +83,10 @@ interface MenuProps {
|
||||
function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
if (triggerRect === undefined) return null;
|
||||
|
||||
useMount(() => {
|
||||
console.log(document.activeElement);
|
||||
});
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
|
||||
|
||||
@@ -155,53 +160,71 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(i: DropdownItem) => {
|
||||
const index = items.findIndex((item) => item === i) ?? null;
|
||||
setSelectedIndex(index);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<Portal name="dropdown">
|
||||
<button aria-hidden title="close" className="fixed inset-0" onClick={onClose} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
ref={containerRef}
|
||||
style={containerStyles}
|
||||
className={classnames(className, 'mt-1 pointer-events-auto fixed z-50')}
|
||||
>
|
||||
<span
|
||||
style={triangleStyles}
|
||||
aria-hidden
|
||||
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
|
||||
/>
|
||||
{containerStyles && (
|
||||
<VStack
|
||||
space={0.5}
|
||||
ref={initMenu}
|
||||
style={menuStyles}
|
||||
className={classnames(
|
||||
className,
|
||||
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
|
||||
'border-gray-200 overflow-auto mb-1 mx-0.5',
|
||||
)}
|
||||
<FocusTrap>
|
||||
<div>
|
||||
<div tabIndex={-1} aria-hidden className="fixed inset-0" onClick={onClose} />
|
||||
<motion.div
|
||||
tabIndex={0}
|
||||
initial={{ opacity: 0, y: -5, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
ref={containerRef}
|
||||
style={containerStyles}
|
||||
className={classnames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.type === 'separator')
|
||||
return <Separator key={i} className="my-1.5" label={item.label} />;
|
||||
if (item.hidden) return null;
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
onSelect={handleSelect}
|
||||
key={i + item.label}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</motion.div>
|
||||
<span
|
||||
aria-hidden
|
||||
style={triangleStyles}
|
||||
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
|
||||
/>
|
||||
{containerStyles && (
|
||||
<VStack
|
||||
space={0.5}
|
||||
ref={initMenu}
|
||||
style={menuStyles}
|
||||
className={classnames(
|
||||
className,
|
||||
'h-auto bg-gray-50 rounded-md shadow-lg dark:shadow-gray-0 py-1.5 border',
|
||||
'border-gray-200 overflow-auto mb-1 mx-0.5',
|
||||
)}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.type === 'separator') {
|
||||
return <Separator key={i} className="my-1.5" label={item.label} />;
|
||||
}
|
||||
if (item.hidden) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
focused={i === selectedIndex}
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
key={i + item.label}
|
||||
item={item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
@@ -210,11 +233,13 @@ interface MenuItemProps {
|
||||
className?: string;
|
||||
item: DropdownItem;
|
||||
onSelect: (item: DropdownItem) => void;
|
||||
onFocus: (item: DropdownItem) => void;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
function MenuItem({ className, focused, item, onSelect, ...props }: MenuItemProps) {
|
||||
function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) {
|
||||
const handleClick = useCallback(() => onSelect?.(item), [item, onSelect]);
|
||||
const handleFocus = useCallback(() => onFocus?.(item), [item, onFocus]);
|
||||
|
||||
const initRef = useCallback(
|
||||
(el: HTMLButtonElement | null) => {
|
||||
@@ -231,9 +256,10 @@ function MenuItem({ className, focused, item, onSelect, ...props }: MenuItemProp
|
||||
return (
|
||||
<button
|
||||
ref={initRef}
|
||||
tabIndex={focused ? 0 : -1}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={(e) => e.currentTarget.focus()}
|
||||
onMouseLeave={(e) => e.currentTarget.blur()}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
className={classnames(
|
||||
className,
|
||||
|
||||
@@ -67,6 +67,8 @@ export function Tabs<T>({
|
||||
className={classnames(
|
||||
tabListClassName,
|
||||
'h-md flex items-center overflow-x-auto pb-0.5 hide-scrollbars',
|
||||
// Give space for button focus states within overflow boundary
|
||||
'px-2 -mx-2',
|
||||
)}
|
||||
>
|
||||
<HStack space={1} className="flex-shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user