Focus traps for dialog and dropdown

This commit is contained in:
Gregory Schier
2023-03-26 23:07:09 -07:00
parent b2dcc38982
commit 4f501abb72
11 changed files with 169 additions and 71 deletions

50
package-lock.json generated
View File

@@ -30,6 +30,7 @@
"classnames": "^2.3.2",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"codemirror": "^6.0.1",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"parse-color": "^1.0.0",
@@ -4363,6 +4364,28 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true
},
"node_modules/focus-trap": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.4.0.tgz",
"integrity": "sha512-yI7FwUqU4TVb+7t6PaQ3spT/42r/KLEi8mtdGoQo2li/kFzmu9URmalTvw7xCCJtSOyhBxscvEAmvjeN9iHARg==",
"dependencies": {
"tabbable": "^6.1.1"
}
},
"node_modules/focus-trap-react": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.1.1.tgz",
"integrity": "sha512-OtLeSIQPKFzMzbLHkGtfZYwGLMhTRHd3CDhfyd0DDx8tvXzlgpseClDiuiKoiIHZtdjsbXTfTmUuuLKaxrwSyQ==",
"dependencies": {
"focus-trap": "^7.4.0",
"tabbable": "^6.1.1"
},
"peerDependencies": {
"prop-types": "^15.8.1",
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
}
},
"node_modules/for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -7006,6 +7029,11 @@
"integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
"dev": true
},
"node_modules/tabbable": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.1.1.tgz",
"integrity": "sha512-4kl5w+nCB44EVRdO0g/UGoOp3vlwgycUVtkk/7DPyeLZUCuNFFKCFG6/t/DgHLrUPHjrZg6s5tNm+56Q2B0xyg=="
},
"node_modules/tailwindcss": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.7.tgz",
@@ -10700,6 +10728,23 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true
},
"focus-trap": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.4.0.tgz",
"integrity": "sha512-yI7FwUqU4TVb+7t6PaQ3spT/42r/KLEi8mtdGoQo2li/kFzmu9URmalTvw7xCCJtSOyhBxscvEAmvjeN9iHARg==",
"requires": {
"tabbable": "^6.1.1"
}
},
"focus-trap-react": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.1.1.tgz",
"integrity": "sha512-OtLeSIQPKFzMzbLHkGtfZYwGLMhTRHd3CDhfyd0DDx8tvXzlgpseClDiuiKoiIHZtdjsbXTfTmUuuLKaxrwSyQ==",
"requires": {
"focus-trap": "^7.4.0",
"tabbable": "^6.1.1"
}
},
"for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -12582,6 +12627,11 @@
"integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
"dev": true
},
"tabbable": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.1.1.tgz",
"integrity": "sha512-4kl5w+nCB44EVRdO0g/UGoOp3vlwgycUVtkk/7DPyeLZUCuNFFKCFG6/t/DgHLrUPHjrZg6s5tNm+56Q2B0xyg=="
},
"tailwindcss": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.7.tgz",

View File

@@ -37,6 +37,7 @@
"classnames": "^2.3.2",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"codemirror": "^6.0.1",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"parse-color": "^1.0.0",

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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">

View File

@@ -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',

View File

@@ -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"

View File

@@ -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,

View File

@@ -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">

View File

@@ -1,3 +1,5 @@
import { useRef } from 'react';
import { useMount } from 'react-use';
import { Button } from '../components/core/Button';
import { HStack } from '../components/core/Stacks';
@@ -5,12 +7,18 @@ interface Props {
hide: () => void;
}
export function Confirm({ hide }: Props) {
const focusRef = (el: HTMLButtonElement | null) => {
el?.focus();
};
return (
<HStack space={2} justifyContent="end">
<Button color="gray" onClick={hide}>
Cancel
</Button>
<Button color="primary">Confirm</Button>
<Button ref={focusRef} color="primary">
Confirm
</Button>
</HStack>
);
}

View File

@@ -1,3 +1,5 @@
const plugin = require('tailwindcss/plugin')
/** @type {import("tailwindcss").Config} */
module.exports = {
darkMode: ["class", "[data-appearance=\"dark\"]"],
@@ -59,7 +61,10 @@ module.exports = {
}
},
plugins: [
require("@tailwindcss/container-queries")
require("@tailwindcss/container-queries"),
plugin(function({ addVariant }) {
addVariant('hocus', ['&:hover', '&:focus'])
})
]
};