mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-19 07:53:54 +01:00
Focus traps for dialog and dropdown
This commit is contained in:
50
package-lock.json
generated
50
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user