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",
|
||||
"playwright": "^1.58.2",
|
||||
"playwright-core": "^1.58.2",
|
||||
"react-focus-lock": "^2.13.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.8.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -1451,15 +1450,6 @@
|
||||
"@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": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -9578,18 +9568,6 @@
|
||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||
"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": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -11226,6 +11204,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -11962,6 +11941,7 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -13524,6 +13504,7 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -14192,6 +14173,7 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -14263,18 +14245,6 @@
|
||||
"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": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
@@ -14288,29 +14258,6 @@
|
||||
"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": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
"mysql2": "^3.18.2",
|
||||
"playwright": "^1.58.2",
|
||||
"playwright-core": "^1.58.2",
|
||||
"react-focus-lock": "^2.13.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.8.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
@@ -10,15 +10,15 @@ interface LogPanelProps {
|
||||
const getLogColor = (level: LogLevel): string => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'text-red-400'
|
||||
return 'text-red-500 dark:text-red-400'
|
||||
case 'warning':
|
||||
return 'text-amber-400'
|
||||
return 'text-amber-500 dark:text-amber-400'
|
||||
case 'success':
|
||||
return 'text-emerald-400'
|
||||
return 'text-emerald-500 dark:text-emerald-400'
|
||||
case 'system':
|
||||
return 'text-blue-400'
|
||||
return 'text-blue-500 dark:text-blue-400'
|
||||
default:
|
||||
return 'text-slate-400'
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,14 +30,14 @@ const LogPanel: React.FC<LogPanelProps> = ({ logs, onClear }) => {
|
||||
}, [logs])
|
||||
|
||||
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-slate-800 px-4 py-2 flex items-center justify-between border-b border-slate-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||
<div className="bg-card rounded-xl shadow-lg border border-border overflow-hidden flex flex-col min-h-[300px] flex-1">
|
||||
<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-muted-foreground text-sm">
|
||||
<Terminal size={16} />
|
||||
<span>执行日志</span>
|
||||
</div>
|
||||
<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}
|
||||
>
|
||||
清空
|
||||
@@ -45,12 +45,12 @@ const LogPanel: React.FC<LogPanelProps> = ({ logs, onClear }) => {
|
||||
</div>
|
||||
<div className="flex-1 p-4 font-mono text-sm overflow-y-auto leading-relaxed">
|
||||
{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) => (
|
||||
<div key={index} className={getLogColor(log.level)}>
|
||||
<span className="text-slate-600">[{log.timestamp}]</span>{' '}
|
||||
<span className="text-slate-500">[{log.level.toUpperCase()}]</span> {log.message}
|
||||
<span className="text-muted-foreground/60">[{log.timestamp}]</span>{' '}
|
||||
<span className="text-muted-foreground">[{log.level.toUpperCase()}]</span> {log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -60,13 +60,13 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
|
||||
const getStatusDot = (status: string) => {
|
||||
if (status === 'completed') return 'bg-emerald-600'
|
||||
if (status === 'active') return 'bg-blue-600 animate-pulse'
|
||||
return 'bg-slate-300'
|
||||
return 'bg-muted-foreground/40'
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
if (status === 'completed') return 'text-emerald-600'
|
||||
if (status === 'active') return 'text-blue-600 font-semibold'
|
||||
return 'text-slate-400'
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
const getDetailText = () => {
|
||||
@@ -121,7 +121,7 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
|
||||
const segments = getSegments()
|
||||
|
||||
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">
|
||||
{PHASES.map((p, index) => {
|
||||
@@ -139,7 +139,7 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
|
||||
</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) => {
|
||||
const prevEnd = index === 0 ? 0 : segments[index - 1].end
|
||||
const segmentWidth = segment.end - prevEnd
|
||||
@@ -161,7 +161,7 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
)
|
||||
@@ -170,11 +170,11 @@ export const SegmentedProgressBar: React.FC<SegmentedProgressBarProps> = ({
|
||||
|
||||
{/* 详细信息 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-slate-600">
|
||||
<span className="text-slate-500">当前进度:</span>
|
||||
<span className="text-slate-800">{getDetailText()}</span>
|
||||
<div className="text-sm text-foreground">
|
||||
<span className="text-muted-foreground">当前进度:</span>
|
||||
<span className="text-foreground">{getDetailText()}</span>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -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