feat(ui): theme-adapt LogPanel/ProgressBar and remove stale deps
Replace hardcoded slate-* colors with shadcn theme tokens (bg-card, text-foreground, text-muted-foreground) in LogPanel and SegmentedProgressBar. Remove react-focus-lock and useDialogFocus hook no longer needed after Modal migration to Radix Dialog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
61
package-lock.json
generated
61
package-lock.json
generated
@@ -30,7 +30,6 @@
|
|||||||
"mysql2": "^3.18.2",
|
"mysql2": "^3.18.2",
|
||||||
"playwright": "^1.58.2",
|
"playwright": "^1.58.2",
|
||||||
"playwright-core": "^1.58.2",
|
"playwright-core": "^1.58.2",
|
||||||
"react-focus-lock": "^2.13.7",
|
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -1451,15 +1450,6 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
|
||||||
"version": "7.28.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
|
||||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -9578,18 +9568,6 @@
|
|||||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/focus-lock": {
|
|
||||||
"version": "1.3.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.6.tgz",
|
|
||||||
"integrity": "sha512-Ik/6OCk9RQQ0T5Xw+hKNLWrjSMtv51dD4GRmJjbD5a58TIEpI5a5iXagKVl3Z5UuyslMCA8Xwnu76jQob62Yhg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -11226,6 +11204,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -11962,6 +11941,7 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -13524,6 +13504,7 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -14192,6 +14173,7 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -14263,18 +14245,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-clientside-effect": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-ma2FePH0z3px2+WOu6h+YycZcEvFmmxIlAb62cF52bG86eMySciO/EQZeQMXd07kPCYB0a1dWDT5J+KE9mCDUw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.12.13"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
@@ -14288,29 +14258,6 @@
|
|||||||
"react": "^19.2.4"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-focus-lock": {
|
|
||||||
"version": "2.13.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.13.7.tgz",
|
|
||||||
"integrity": "sha512-20lpZHEQrXPb+pp1tzd4ULL6DyO5D2KnR0G69tTDdydrmNhU7pdFmbQUYVyHUgp+xN29IuFR0PVuhOmvaZL9Og==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.0.0",
|
|
||||||
"focus-lock": "^1.3.6",
|
|
||||||
"prop-types": "^15.6.2",
|
|
||||||
"react-clientside-effect": "^1.2.7",
|
|
||||||
"use-callback-ref": "^1.3.3",
|
|
||||||
"use-sidecar": "^1.1.3"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
|||||||
@@ -54,7 +54,6 @@
|
|||||||
"mysql2": "^3.18.2",
|
"mysql2": "^3.18.2",
|
||||||
"playwright": "^1.58.2",
|
"playwright": "^1.58.2",
|
||||||
"playwright-core": "^1.58.2",
|
"playwright-core": "^1.58.2",
|
||||||
"react-focus-lock": "^2.13.7",
|
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ interface LogPanelProps {
|
|||||||
const getLogColor = (level: LogLevel): string => {
|
const getLogColor = (level: LogLevel): string => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'text-red-400'
|
return 'text-red-500 dark:text-red-400'
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return 'text-amber-400'
|
return 'text-amber-500 dark:text-amber-400'
|
||||||
case 'success':
|
case 'success':
|
||||||
return 'text-emerald-400'
|
return 'text-emerald-500 dark:text-emerald-400'
|
||||||
case 'system':
|
case 'system':
|
||||||
return 'text-blue-400'
|
return 'text-blue-500 dark:text-blue-400'
|
||||||
default:
|
default:
|
||||||
return 'text-slate-400'
|
return 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,14 +30,14 @@ const LogPanel: React.FC<LogPanelProps> = ({ logs, onClear }) => {
|
|||||||
}, [logs])
|
}, [logs])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-900 rounded-xl shadow-lg border border-slate-700 overflow-hidden flex flex-col min-h-[300px] flex-1">
|
<div className="bg-card rounded-xl shadow-lg border border-border overflow-hidden flex flex-col min-h-[300px] flex-1">
|
||||||
<div className="bg-slate-800 px-4 py-2 flex items-center justify-between border-b border-slate-700 flex-shrink-0">
|
<div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||||
<Terminal size={16} />
|
<Terminal size={16} />
|
||||||
<span>执行日志</span>
|
<span>执行日志</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-xs text-slate-400 hover:text-white transition-colors"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
>
|
>
|
||||||
清空
|
清空
|
||||||
@@ -45,12 +45,12 @@ const LogPanel: React.FC<LogPanelProps> = ({ logs, onClear }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-4 font-mono text-sm overflow-y-auto leading-relaxed">
|
<div className="flex-1 p-4 font-mono text-sm overflow-y-auto leading-relaxed">
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="text-slate-500 text-center py-8">等待执行...</div>
|
<div className="text-muted-foreground text-center py-8">等待执行...</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log, index) => (
|
logs.map((log, index) => (
|
||||||
<div key={index} className={getLogColor(log.level)}>
|
<div key={index} className={getLogColor(log.level)}>
|
||||||
<span className="text-slate-600">[{log.timestamp}]</span>{' '}
|
<span className="text-muted-foreground/60">[{log.timestamp}]</span>{' '}
|
||||||
<span className="text-slate-500">[{log.level.toUpperCase()}]</span> {log.message}
|
<span className="text-muted-foreground">[{log.level.toUpperCase()}]</span> {log.message}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
|
|||||||
const getStatusDot = (status: string) => {
|
const getStatusDot = (status: string) => {
|
||||||
if (status === 'completed') return 'bg-emerald-600'
|
if (status === 'completed') return 'bg-emerald-600'
|
||||||
if (status === 'active') return 'bg-blue-600 animate-pulse'
|
if (status === 'active') return 'bg-blue-600 animate-pulse'
|
||||||
return 'bg-slate-300'
|
return 'bg-muted-foreground/40'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusText = (status: string) => {
|
const getStatusText = (status: string) => {
|
||||||
if (status === 'completed') return 'text-emerald-600'
|
if (status === 'completed') return 'text-emerald-600'
|
||||||
if (status === 'active') return 'text-blue-600 font-semibold'
|
if (status === 'active') return 'text-blue-600 font-semibold'
|
||||||
return 'text-slate-400'
|
return 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDetailText = () => {
|
const getDetailText = () => {
|
||||||
@@ -121,7 +121,7 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
|
|||||||
const segments = getSegments()
|
const segments = getSegments()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
<div className="bg-card rounded-xl shadow-sm border border-border p-5">
|
||||||
{/* 阶段标签 */}
|
{/* 阶段标签 */}
|
||||||
<div className="flex justify-between mb-3">
|
<div className="flex justify-between mb-3">
|
||||||
{PHASES.map((p, index) => {
|
{PHASES.map((p, index) => {
|
||||||
@@ -139,7 +139,7 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分段进度条 */}
|
{/* 分段进度条 */}
|
||||||
<div className="relative h-3 bg-slate-100 rounded-full overflow-hidden mb-3">
|
<div className="relative h-3 bg-muted rounded-full overflow-hidden mb-3">
|
||||||
{segments.map((segment, index) => {
|
{segments.map((segment, index) => {
|
||||||
const prevEnd = index === 0 ? 0 : segments[index - 1].end
|
const prevEnd = index === 0 ? 0 : segments[index - 1].end
|
||||||
const segmentWidth = segment.end - prevEnd
|
const segmentWidth = segment.end - prevEnd
|
||||||
@@ -161,7 +161,7 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{index < PHASES.length - 1 && (
|
{index < PHASES.length - 1 && (
|
||||||
<div className="absolute right-0 top-0 h-full w-px bg-white/50" />
|
<div className="absolute right-0 top-0 h-full w-px bg-background/50" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -170,11 +170,11 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
|
|||||||
|
|
||||||
{/* 详细信息 */}
|
{/* 详细信息 */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="text-sm text-slate-600">
|
<div className="text-sm text-foreground">
|
||||||
<span className="text-slate-500">当前进度:</span>
|
<span className="text-muted-foreground">当前进度:</span>
|
||||||
<span className="text-slate-800">{getDetailText()}</span>
|
<span className="text-foreground">{getDetailText()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl font-bold text-slate-800">{Math.round(progress)}%</div>
|
<div className="text-xl font-bold text-foreground">{Math.round(progress)}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,322 +0,0 @@
|
|||||||
import { useEffect, RefObject } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for configuring dialog focus management
|
|
||||||
*/
|
|
||||||
export interface UseDialogFocusOptions {
|
|
||||||
/** Whether the dialog is currently open */
|
|
||||||
isOpen: boolean
|
|
||||||
/** Ref to the dialog container element */
|
|
||||||
dialogRef: RefObject<HTMLElement | null>
|
|
||||||
/** Callback to close the dialog */
|
|
||||||
onClose: () => void
|
|
||||||
/** Optional ref to the element that triggered opening the dialog */
|
|
||||||
triggerRef?: RefObject<HTMLElement | null>
|
|
||||||
/** Optional selector for the element to focus initially inside the dialog */
|
|
||||||
initialFocusSelector?: string
|
|
||||||
/** Whether to lock body scroll when dialog is open (default: true) */
|
|
||||||
lockBodyScroll?: boolean
|
|
||||||
/**
|
|
||||||
* Whether Escape key should close the dialog (default: true)
|
|
||||||
* Can be a boolean or a function that receives the keyboard event and returns a boolean
|
|
||||||
*/
|
|
||||||
shouldCloseOnEscape?: boolean | ((event: KeyboardEvent) => boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return type for useDialogFocus hook
|
|
||||||
*/
|
|
||||||
export interface UseDialogFocusReturn {
|
|
||||||
/** Whether focus lock should be enabled */
|
|
||||||
focusLockEnabled: boolean
|
|
||||||
/** Props to spread on FocusLock component */
|
|
||||||
focusLockProps: {
|
|
||||||
disabled: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React Hook for managing focus in modal dialogs
|
|
||||||
*
|
|
||||||
* Integrates with react-focus-lock to provide:
|
|
||||||
* - Focus trapping within dialog
|
|
||||||
* - Initial focus management (first interactive element or custom selector)
|
|
||||||
* - Focus restoration to trigger element on close
|
|
||||||
* - Escape key handling
|
|
||||||
* - Body scroll locking
|
|
||||||
*
|
|
||||||
* @param options - Configuration options for focus management
|
|
||||||
* @returns Focus lock props and state
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* function MyDialog({ isOpen, onClose, isExecuting }) {
|
|
||||||
* const dialogRef = useRef<HTMLDivElement>(null)
|
|
||||||
* const triggerRef = useRef<HTMLButtonElement>(null)
|
|
||||||
* const { focusLockEnabled, focusLockProps } = useDialogFocus({
|
|
||||||
* isOpen,
|
|
||||||
* dialogRef,
|
|
||||||
* onClose,
|
|
||||||
* triggerRef,
|
|
||||||
* shouldCloseOnEscape: () => !isExecuting // Only close when not executing
|
|
||||||
* })
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <FocusLock {...focusLockProps}>
|
|
||||||
* <div ref={dialogRef} role="dialog" aria-modal="true">
|
|
||||||
* <h2>Dialog Title</h2>
|
|
||||||
* <button onClick={onClose}>Close</button>
|
|
||||||
* </div>
|
|
||||||
* </FocusLock>
|
|
||||||
* )
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useDialogFocus(options: UseDialogFocusOptions): UseDialogFocusReturn {
|
|
||||||
const {
|
|
||||||
isOpen,
|
|
||||||
dialogRef,
|
|
||||||
onClose,
|
|
||||||
triggerRef,
|
|
||||||
initialFocusSelector,
|
|
||||||
lockBodyScroll = true,
|
|
||||||
shouldCloseOnEscape = true
|
|
||||||
} = options
|
|
||||||
|
|
||||||
// Handle Escape key press
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
|
||||||
if (event.key === 'Escape' || event.keyCode === 27) {
|
|
||||||
// Check if we should close on escape
|
|
||||||
let shouldClose = true
|
|
||||||
if (shouldCloseOnEscape !== undefined) {
|
|
||||||
if (typeof shouldCloseOnEscape === 'function') {
|
|
||||||
shouldClose = shouldCloseOnEscape(event)
|
|
||||||
} else {
|
|
||||||
shouldClose = shouldCloseOnEscape
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldClose) {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
return (): void => {
|
|
||||||
window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}
|
|
||||||
}, [isOpen, onClose, shouldCloseOnEscape])
|
|
||||||
|
|
||||||
// Manage body scroll locking
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lockBodyScroll) return
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
// Store current scroll position
|
|
||||||
const scrollY = window.scrollY
|
|
||||||
document.body.style.position = 'fixed'
|
|
||||||
document.body.style.top = `-${scrollY}px`
|
|
||||||
document.body.style.left = '0'
|
|
||||||
document.body.style.right = '0'
|
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
} else {
|
|
||||||
// Restore scroll position
|
|
||||||
const scrollY = document.body.style.top
|
|
||||||
document.body.style.position = ''
|
|
||||||
document.body.style.top = ''
|
|
||||||
document.body.style.left = ''
|
|
||||||
document.body.style.right = ''
|
|
||||||
document.body.style.overflow = ''
|
|
||||||
if (scrollY) {
|
|
||||||
window.scrollTo(0, parseInt(scrollY, 10) * -1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
// Cleanup on unmount or when isOpen changes
|
|
||||||
if (isOpen) {
|
|
||||||
const scrollY = document.body.style.top
|
|
||||||
document.body.style.position = ''
|
|
||||||
document.body.style.top = ''
|
|
||||||
document.body.style.left = ''
|
|
||||||
document.body.style.right = ''
|
|
||||||
document.body.style.overflow = ''
|
|
||||||
if (scrollY) {
|
|
||||||
window.scrollTo(0, parseInt(scrollY, 10) * -1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isOpen, lockBodyScroll])
|
|
||||||
|
|
||||||
// Manage initial focus when dialog opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen || !dialogRef.current) return
|
|
||||||
|
|
||||||
const setupFocus = (): void => {
|
|
||||||
const dialogElement = dialogRef.current
|
|
||||||
if (!dialogElement) return
|
|
||||||
|
|
||||||
// If initialFocusSelector is provided, try to focus that element
|
|
||||||
if (initialFocusSelector) {
|
|
||||||
const focusElement = dialogElement.querySelector(initialFocusSelector) as HTMLElement
|
|
||||||
if (focusElement && typeof focusElement.focus === 'function') {
|
|
||||||
// Check if element is visible and focusable
|
|
||||||
const style = window.getComputedStyle(focusElement)
|
|
||||||
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
focusElement.focus({ preventScroll: true })
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Fallback: element found but not visible, log warning and try default
|
|
||||||
console.warn(`Focus element found but not visible: ${initialFocusSelector}`)
|
|
||||||
} else {
|
|
||||||
// Fallback: element not found, log warning and try default
|
|
||||||
console.warn(`Focus element not found for selector: ${initialFocusSelector}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, focus the first interactive element
|
|
||||||
const focusableSelectors = [
|
|
||||||
'button:not([disabled]):not([tabindex="-1"])',
|
|
||||||
'a[href]',
|
|
||||||
'input:not([disabled]):not([tabindex="-1"])',
|
|
||||||
'select:not([disabled]):not([tabindex="-1"])',
|
|
||||||
'textarea:not([disabled]):not([tabindex="-1"])',
|
|
||||||
'[tabindex]:not([tabindex="-1"])'
|
|
||||||
]
|
|
||||||
const firstFocusable = dialogElement.querySelector(
|
|
||||||
focusableSelectors.join(', ')
|
|
||||||
) as HTMLElement
|
|
||||||
if (firstFocusable && typeof firstFocusable.focus === 'function') {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
firstFocusable.focus({ preventScroll: true })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay to ensure portal content is rendered
|
|
||||||
requestAnimationFrame(setupFocus)
|
|
||||||
}, [isOpen, dialogRef, initialFocusSelector])
|
|
||||||
|
|
||||||
// Restore focus to trigger element when dialog closes
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen || !triggerRef?.current) return
|
|
||||||
|
|
||||||
const restoreFocus = (): void => {
|
|
||||||
const triggerElement = triggerRef.current
|
|
||||||
|
|
||||||
// Check if element still exists in DOM
|
|
||||||
if (!triggerElement || !document.contains(triggerElement)) {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.warn('[useDialogFocus] Trigger element not found in DOM, cannot restore focus')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if element has a focus method
|
|
||||||
if (typeof triggerElement.focus !== 'function') {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.warn('[useDialogFocus] Trigger element does not have a focus method')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if element is visible (not display: none)
|
|
||||||
const style = window.getComputedStyle(triggerElement)
|
|
||||||
if (style.display === 'none') {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.warn('[useDialogFocus] Trigger element is display: none, cannot restore focus')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (style.visibility === 'hidden') {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.warn(
|
|
||||||
'[useDialogFocus] Trigger element is visibility: hidden, cannot restore focus'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if element is disabled
|
|
||||||
if (triggerElement instanceof HTMLButtonElement && triggerElement.disabled) {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.warn('[useDialogFocus] Trigger element is disabled, cannot restore focus')
|
|
||||||
}
|
|
||||||
// Try to find nearest enabled ancestor or fallback to body
|
|
||||||
const focusableParent = findNearestFocusableElement(triggerElement)
|
|
||||||
if (focusableParent) {
|
|
||||||
focusableParent.focus({ preventScroll: true })
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.info('[useDialogFocus] Restored focus to nearest focusable ancestor')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// All checks passed, restore focus
|
|
||||||
try {
|
|
||||||
triggerElement.focus({ preventScroll: true })
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.info('[useDialogFocus] Successfully restored focus to trigger element')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.error('[useDialogFocus] Error restoring focus:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to find nearest focusable ancestor
|
|
||||||
const findNearestFocusableElement = (element: HTMLElement): HTMLElement | null => {
|
|
||||||
let parent = element.parentElement
|
|
||||||
const focusableSelectors = [
|
|
||||||
'button:not([disabled]):not([tabindex="-1"])',
|
|
||||||
'a[href]',
|
|
||||||
'input:not([disabled]):not([tabindex="-1"])',
|
|
||||||
'select:not([disabled]):not([tabindex="-1"])',
|
|
||||||
'textarea:not([disabled]):not([tabindex="-1"])',
|
|
||||||
'[tabindex]:not([tabindex="-1"])'
|
|
||||||
]
|
|
||||||
|
|
||||||
while (parent && parent !== document.body) {
|
|
||||||
// Check if parent itself is focusable
|
|
||||||
if (
|
|
||||||
focusableSelectors.some((selector) => parent?.matches(selector)) &&
|
|
||||||
window.getComputedStyle(parent).display !== 'none' &&
|
|
||||||
window.getComputedStyle(parent).visibility !== 'hidden'
|
|
||||||
) {
|
|
||||||
return parent
|
|
||||||
}
|
|
||||||
// Check if parent contains focusable element
|
|
||||||
const focusableChild = parent.querySelector(focusableSelectors.join(', ')) as HTMLElement
|
|
||||||
if (focusableChild) {
|
|
||||||
return focusableChild
|
|
||||||
}
|
|
||||||
parent = parent.parentElement
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use microtask queue to ensure this runs after DOM cleanup
|
|
||||||
queueMicrotask(restoreFocus)
|
|
||||||
}, [isOpen, triggerRef])
|
|
||||||
|
|
||||||
// Return focus lock configuration
|
|
||||||
return {
|
|
||||||
focusLockEnabled: isOpen,
|
|
||||||
focusLockProps: {
|
|
||||||
disabled: !isOpen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useDialogFocus
|
|
||||||
Reference in New Issue
Block a user