-
{item.fileName}
+ {item.fileName}
{statusLabel}
diff --git a/src/hooks/useModalFocus.ts b/src/hooks/useModalFocus.ts
new file mode 100644
index 0000000..8c88c0c
--- /dev/null
+++ b/src/hooks/useModalFocus.ts
@@ -0,0 +1,122 @@
+import { useEffect, useRef } from 'react';
+
+const FOCUSABLE_SELECTOR = [
+ 'a[href]',
+ 'area[href]',
+ 'button:not([disabled])',
+ 'input:not([disabled]):not([type="hidden"])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ 'iframe',
+ 'object',
+ 'embed',
+ '[contenteditable="true"]',
+ '[tabindex]:not([tabindex="-1"])'
+].join(',');
+
+const getFocusableElements = (surface: HTMLElement): HTMLElement[] => {
+ return Array.from(surface.querySelectorAll(FOCUSABLE_SELECTOR)).filter(element => {
+ const style = window.getComputedStyle(element);
+ return style.display !== 'none'
+ && style.visibility !== 'hidden'
+ && !element.closest('[aria-hidden="true"], [inert]');
+ });
+};
+
+const getTopmostModal = (): HTMLElement | null => {
+ const surfaces = Array.from(document.querySelectorAll('[data-modal-surface="true"]'));
+ let topmost: HTMLElement | null = null;
+ let topmostZIndex = Number.NEGATIVE_INFINITY;
+
+ for (const surface of surfaces) {
+ // Modal stacking is applied to the backdrop, not the panel itself. This
+ // matters when a child modal (for example duplicate resolution) is
+ // rendered before its parent panel in the DOM.
+ const stackingElement = surface.closest('.app-modal-backdrop') || surface;
+ const parsedZIndex = Number.parseInt(window.getComputedStyle(stackingElement).zIndex, 10);
+ const zIndex = Number.isFinite(parsedZIndex) ? parsedZIndex : 0;
+ // Later DOM order wins ties, matching browser painting order for equal
+ // z-index surfaces and keeping the stack deterministic.
+ if (zIndex >= topmostZIndex) {
+ topmost = surface;
+ topmostZIndex = zIndex;
+ }
+ }
+
+ return topmost;
+};
+
+export const isTopmostModal = (surface: HTMLElement | null): boolean =>
+ surface !== null && getTopmostModal() === surface;
+
+export const useModalFocus = (enabled = true) => {
+ const surfaceRef = useRef(null);
+ const previousFocusRef = useRef(null);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const surface = surfaceRef.current;
+ if (!surface) return;
+
+ const activeElement = document.activeElement;
+ previousFocusRef.current = activeElement instanceof HTMLElement && !surface.contains(activeElement)
+ ? activeElement
+ : null;
+
+ const focusInitialElement = () => {
+ if (!isTopmostModal(surface)) return;
+ const initialElement = surface.querySelector('[data-modal-autofocus="true"]')
+ || getFocusableElements(surface)[0]
+ || surface;
+ initialElement.focus({ preventScroll: true });
+ };
+
+ const initialFocusFrame = window.requestAnimationFrame(focusInitialElement);
+ const handleFocusIn = (event: FocusEvent) => {
+ if (!isTopmostModal(surface) || surface.contains(event.target as Node)) return;
+ focusInitialElement();
+ };
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (!isTopmostModal(surface) || event.key !== 'Tab') return;
+
+ const focusableElements = getFocusableElements(surface);
+ if (focusableElements.length === 0) {
+ event.preventDefault();
+ surface.focus({ preventScroll: true });
+ return;
+ }
+
+ const currentIndex = focusableElements.indexOf(document.activeElement as HTMLElement);
+ const isLeavingForward = !event.shiftKey && (currentIndex < 0 || currentIndex === focusableElements.length - 1);
+ const isLeavingBackward = event.shiftKey && currentIndex <= 0;
+ if (!isLeavingForward && !isLeavingBackward) return;
+
+ event.preventDefault();
+ const nextElement = event.shiftKey
+ ? focusableElements[focusableElements.length - 1]
+ : focusableElements[0];
+ nextElement.focus({ preventScroll: true });
+ };
+
+ document.addEventListener('focusin', handleFocusIn);
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.cancelAnimationFrame(initialFocusFrame);
+ document.removeEventListener('focusin', handleFocusIn);
+ document.removeEventListener('keydown', handleKeyDown);
+
+ const previousFocus = previousFocusRef.current;
+ if (!previousFocus?.isConnected) return;
+ window.requestAnimationFrame(() => {
+ const topmostModal = getTopmostModal();
+ if (!topmostModal || topmostModal.contains(previousFocus)) {
+ previousFocus.focus({ preventScroll: true });
+ }
+ });
+ };
+ }, [enabled]);
+
+ return surfaceRef;
+};
diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts
index 3cf3ccd..6d58ace 100644
--- a/src/i18n/catalogs/en.ts
+++ b/src/i18n/catalogs/en.ts
@@ -292,6 +292,7 @@ const common = {
enableFromSettings: 'You can enable storage anytime from Settings > Integrations.',
later: 'Later',
enabling: 'Enabling...',
+ waitingForPrompt: 'Waiting for system prompt…',
timeout: 'Credential storage request timed out. You can select Later and try again.',
unavailable: '{{store}} is unavailable.',
accessRequired: 'Grant credential-store access before regenerating the pairing token.',
diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts
index f442528..3bc66b4 100644
--- a/src/i18n/catalogs/fa.ts
+++ b/src/i18n/catalogs/fa.ts
@@ -292,6 +292,7 @@ const fa = {
enableFromSettings: 'در هر زمان میتوانید ذخیرهسازی را از تنظیمات > یکپارچهسازی فعال کنید.',
later: 'بعداً',
enabling: 'در حال فعالسازی…',
+ waitingForPrompt: 'در انتظار پیام سیستم…',
timeout: 'مهلت درخواست ذخیرهسازی اعتبارنامه به پایان رسید. میتوانید بعداً را انتخاب کرده و دوباره امتحان کنید.',
unavailable: '{{store}} در دسترس نیست.',
accessRequired: 'پیش از ایجاد مجدد توکن جفتسازی، دسترسی به مخزن اعتبارنامهها را اعطا کنید.',
diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts
index 1ef4afe..c233058 100644
--- a/src/i18n/catalogs/he.ts
+++ b/src/i18n/catalogs/he.ts
@@ -292,6 +292,7 @@ const he = {
enableFromSettings: 'ניתן להפעיל את האחסון בכל עת דרך הגדרות > שילובים.',
later: 'מאוחר יותר',
enabling: 'מפעיל…',
+ waitingForPrompt: 'ממתין להנחיית המערכת…',
timeout: 'בקשת האחסון של האישורים הסתיימה. ניתן לבחור "מאוחר יותר" ולנסות שוב.',
unavailable: '{{store}} אינו זמין.',
accessRequired: 'יש להעניק גישה לאחסון האישורים לפני יצירה מחדש של אסימון הצימוד.',
diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts
index f00a99e..9dd5b5c 100644
--- a/src/i18n/catalogs/ru.ts
+++ b/src/i18n/catalogs/ru.ts
@@ -292,6 +292,7 @@ const ru = {
enableFromSettings: 'Вы можете включить хранилище в любое время в меню «Настройки» > «Интеграции».',
later: 'Позже',
enabling: 'Включение…',
+ waitingForPrompt: 'Ожидание системного запроса…',
timeout: 'Время ожидания запроса к хранилищу учётных данных истекло. Вы можете выбрать «Позже» и попробовать снова.',
unavailable: 'Хранилище {{store}} недоступно.',
accessRequired: 'Предоставьте доступ к хранилищу учётных данных перед повторной генерацией токена сопряжения.',
diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts
index fbbcdff..ba0bba3 100644
--- a/src/i18n/catalogs/uk.ts
+++ b/src/i18n/catalogs/uk.ts
@@ -292,6 +292,7 @@ const uk = {
enableFromSettings: 'Ви можете увімкнути сховище будь-коли в Налаштування > Інтеграції.',
later: 'Пізніше',
enabling: 'Увімкнення…',
+ waitingForPrompt: 'Очікування системного запиту…',
timeout: 'Час очікування запиту до сховища облікових даних вичерпано. Ви можете вибрати "Пізніше" і спробувати ще раз.',
unavailable: '{{store}} недоступно.',
accessRequired: 'Надайте доступ до сховища облікових даних перед повторною генерацією токена підключення.',
diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts
index 7fe4e1a..ebe04d5 100644
--- a/src/i18n/catalogs/zh-CN.ts
+++ b/src/i18n/catalogs/zh-CN.ts
@@ -292,6 +292,7 @@ const zhCN = {
enableFromSettings: '您可以随时从“设置 > 集成”中启用存储。',
later: '稍后',
enabling: '正在启用…',
+ waitingForPrompt: '正在等待系统提示…',
timeout: '凭据存储请求超时。您可以选择“稍后”并重试。',
unavailable: '{{store}}不可用。',
accessRequired: '在重新生成配对令牌之前,请授予凭据存储访问权限。',
diff --git a/src/index.css b/src/index.css
index 9961f37..90b65f3 100644
--- a/src/index.css
+++ b/src/index.css
@@ -604,9 +604,11 @@ html[data-list-density="relaxed"] {
.app-modal-backdrop {
/* Keep the scrim animation from applying opacity to the modal subtree. */
background-color: hsl(0 0% 0% / 0.2);
- animation: modal-backdrop-in 150ms ease-out;
+ animation: modal-backdrop-in 167ms cubic-bezier(0, 0, 0, 1);
overflow: auto;
+ overscroll-behavior: contain;
padding: 16px;
+ scrollbar-gutter: stable;
}
.app-shell--window-chrome .app-modal-backdrop {
@@ -623,12 +625,21 @@ html[data-list-density="relaxed"] {
.app-modal {
background: hsl(var(--surface-overlay));
- backdrop-filter: blur(40px);
- -webkit-backdrop-filter: blur(40px);
border: 1px solid hsl(var(--border-modal));
border-radius: 12px;
box-shadow: 0 20px 40px hsl(var(--shadow-color));
- animation: modal-in 150ms ease-out;
+ animation: modal-in 167ms cubic-bezier(0.2, 0.8, 0.2, 1);
+ contain: layout;
+ }
+
+ .keychain-modal {
+ max-width: 560px;
+ max-height: min(680px, 100%);
+ }
+
+ .properties-modal {
+ max-width: 100%;
+ max-height: 100%;
}
/* Add Download window */
@@ -641,8 +652,6 @@ html[data-list-density="relaxed"] {
--add-highlight: hsl(0 0% 100% / 0.08);
background:
linear-gradient(180deg, hsl(var(--surface-overlay) / 0.94), hsl(var(--bg-modal) / 0.98));
- backdrop-filter: blur(22px) saturate(1.12);
- -webkit-backdrop-filter: blur(22px) saturate(1.12);
border-color: hsl(var(--border-modal));
border-radius: 14px;
box-shadow:
@@ -3350,7 +3359,7 @@ html[dir="rtl"] .download-context-menu-chevron {
}
@keyframes modal-in {
- from { opacity: 0; transform: scale(0.98); }
+ from { opacity: 0; transform: translateY(4px) scale(0.99); }
to { opacity: 1; transform: scale(1); }
}
@@ -3376,6 +3385,13 @@ html[dir="rtl"] .download-context-menu-chevron {
}
@media (prefers-reduced-motion: reduce) {
+ .app-modal-backdrop,
+ .app-modal {
+ animation: none !important;
+ transform: none !important;
+ opacity: 1 !important;
+ }
+
*,
*::before,
*::after {