fix(threads): keep Tab and focus inside the thread create pane (#3296)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 17:41:14 +02:00
1 parent 663572fd10
commit b8f3dbddbf
2 files changed
+25 -2

No files matched your search

@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes'; import {Routes} from '@app/app/Routes';
import {CHANNEL_TEXTAREA_SELECTOR} from '@app/features/app/keybindings/utils/EditableElement'; import {
CHANNEL_TEXTAREA_SELECTOR,
isChannelTextareaElement,
isEditableElement,
} from '@app/features/app/keybindings/utils/EditableElement';
import {useLocation} from '@app/features/platform/components/router/RouterReact'; import {useLocation} from '@app/features/platform/components/router/RouterReact';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus'; import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager'; import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
@@ -29,6 +33,7 @@ const canRedirectTabToComposer = (composer: HTMLTextAreaElement | null): compose
if (composer == null || composer.disabled || composer.getAttribute('aria-disabled') === 'true') return false; if (composer == null || composer.disabled || composer.getAttribute('aria-disabled') === 'true') return false;
if (isElementInert(composer)) return false; if (isElementInert(composer)) return false;
const active = document.activeElement; const active = document.activeElement;
if (isEditableElement(active) && !isChannelTextareaElement(active)) return false;
return !(active instanceof Element && active.closest(FOCUS_TRAPPING_OVERLAY_SELECTOR) != null); return !(active instanceof Element && active.closest(FOCUS_TRAPPING_OVERLAY_SELECTOR) != null);
}; };
@@ -11,6 +11,7 @@ import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils'; import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils';
import {formatUploadingAttachmentSummary} from '@app/features/messaging/utils/UploadingAttachmentLabelUtils'; import {formatUploadingAttachmentSummary} from '@app/features/messaging/utils/UploadingAttachmentLabelUtils';
import Permission from '@app/features/permissions/state/Permission'; import Permission from '@app/features/permissions/state/Permission';
import {shouldDisableAutofocusOnMobile} from '@app/features/platform/utils/AutofocusUtils';
import * as ThreadCommands from '@app/features/threads/commands/ThreadCommands'; import * as ThreadCommands from '@app/features/threads/commands/ThreadCommands';
import {openThread} from '@app/features/threads/commands/ThreadNavigation'; import {openThread} from '@app/features/threads/commands/ThreadNavigation';
import styles from '@app/features/threads/components/ThreadCreatePane.module.css'; import styles from '@app/features/threads/components/ThreadCreatePane.module.css';
@@ -71,6 +72,23 @@ export const ThreadCreatePane = observer(({parent, messageId}: ThreadCreatePaneP
const [isPrivate, setIsPrivate] = useState(false); const [isPrivate, setIsPrivate] = useState(false);
const nameRef = useRef(name); const nameRef = useRef(name);
nameRef.current = name; nameRef.current = name;
const nameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const input = nameInputRef.current;
if (!input || shouldDisableAutofocusOnMobile()) return;
const inertRoot = input.closest('[inert]');
if (!inertRoot) {
input.focus();
return;
}
const observer = new MutationObserver(() => {
if (input.closest('[inert]')) return;
observer.disconnect();
input.focus();
});
observer.observe(inertRoot, {attributes: true, attributeFilter: ['inert']});
return () => observer.disconnect();
}, []);
const sourceMessage = messageId ? Messages.getMessage(parent.id, messageId) : undefined; const sourceMessage = messageId ? Messages.getMessage(parent.id, messageId) : undefined;
const draftKey = `thread-create:${parent.id}`; const draftKey = `thread-create:${parent.id}`;
const accountKey = Users.viewAccountKey; const accountKey = Users.viewAccountKey;
@@ -138,6 +156,7 @@ export const ThreadCreatePane = observer(({parent, messageId}: ThreadCreatePaneP
<ChatsIcon className={styles.iconGlyph} data-flx="threads.thread-create-pane.icon-glyph" /> <ChatsIcon className={styles.iconGlyph} data-flx="threads.thread-create-pane.icon-glyph" />
</div> </div>
<Input <Input
ref={nameInputRef}
value={name} value={name}
onChange={(event) => { onChange={(event) => {
setName(event.target.value); setName(event.target.value);
@@ -146,7 +165,6 @@ export const ThreadCreatePane = observer(({parent, messageId}: ThreadCreatePaneP
label={i18n._(D.THREAD_NAME_DESCRIPTOR)} label={i18n._(D.THREAD_NAME_DESCRIPTOR)}
placeholder={sourceMessage?.content.slice(0, THREAD_NAME_MAX_LENGTH) || i18n._(D.NEW_THREAD_DESCRIPTOR)} placeholder={sourceMessage?.content.slice(0, THREAD_NAME_MAX_LENGTH) || i18n._(D.NEW_THREAD_DESCRIPTOR)}
maxLength={THREAD_NAME_MAX_LENGTH} maxLength={THREAD_NAME_MAX_LENGTH}
autoFocus
autoComplete="off" autoComplete="off"
error={nameError} error={nameError}
data-flx="threads.thread-create-pane.input.name" data-flx="threads.thread-create-pane.input.name"