mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): gate one-Tab message focus behind an experiment (#2762)
This commit is contained in:
1 parent
1281045648
commit
4b278a0da8
35 files changed
+1864
-62
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {CHANNEL_TEXTAREA_SELECTOR} from '@app/features/app/keybindings/utils/EditableElement';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
recordPointerActivationFocusTarget,
|
||||
} from '@app/features/ui/utils/PointerActivationFocus';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useEffect, useLayoutEffect, useMemo} from 'react';
|
||||
|
||||
const FOCUS_TRAPPING_OVERLAY_SELECTOR = [
|
||||
'[role="dialog"]',
|
||||
@@ -86,9 +87,15 @@ export const KeyboardModeListener = observer(() => {
|
||||
window.removeEventListener('pointerdown', handlePointer, true);
|
||||
};
|
||||
}, [isAuthRoute]);
|
||||
useEffect(() => {
|
||||
const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled;
|
||||
useLayoutEffect(() => {
|
||||
if (!keyboardNavigationEnabled) return;
|
||||
FocusRingManager.setRingsEnabled(keyboardModeEnabled);
|
||||
}, [keyboardModeEnabled]);
|
||||
}, [keyboardModeEnabled, keyboardNavigationEnabled]);
|
||||
useEffect(() => {
|
||||
if (keyboardNavigationEnabled) return;
|
||||
FocusRingManager.setRingsEnabled(keyboardModeEnabled);
|
||||
}, [keyboardModeEnabled, keyboardNavigationEnabled]);
|
||||
useEffect(() => {
|
||||
const pendingFrames = new Set<number>();
|
||||
const handlePointerActivation = (event: MouseEvent) => {
|
||||
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
} from '@app/features/channel/components/MessageGroup';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import {type ChannelStreamItem, ChannelStreamType} from '@app/features/messaging/utils/MessageGroupingUtils';
|
||||
import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import type {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
|
||||
import React, {useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef} from 'react';
|
||||
|
||||
const MESSAGE_SCROLLER_SELECTOR = '[data-fluxer-scroll-container="true"]';
|
||||
const SCROLLER_BOTTOM_EPSILON = 1;
|
||||
@@ -99,7 +102,13 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
} = props;
|
||||
const {i18n} = useLingui();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const scrollToBottomFrameRef = useRef<number | null>(null);
|
||||
const wasRevealedRef = useRef(revealed);
|
||||
const revealedByKeyboardRef = useRef(false);
|
||||
const focusWithinContentRef = useRef(false);
|
||||
const contentId = useId();
|
||||
const messageSummary = useMemo(() => {
|
||||
let firstMessageId: string | null = null;
|
||||
let totalMessageCount = 0;
|
||||
@@ -123,34 +132,93 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
const handleClick = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const scroller = container?.closest(MESSAGE_SCROLLER_SELECTOR) as HTMLElement | null;
|
||||
if (scroller) {
|
||||
const wasAtBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLER_BOTTOM_EPSILON;
|
||||
if (revealed) {
|
||||
onReveal(null);
|
||||
if (wasAtBottom) {
|
||||
scheduleScrollToBottom(scroller);
|
||||
}
|
||||
} else {
|
||||
if (messageSummary.firstMessageId) {
|
||||
onReveal(messageSummary.firstMessageId);
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
revealedByKeyboardRef.current = event.detail === 0;
|
||||
const container = containerRef.current;
|
||||
const scroller = container?.closest(MESSAGE_SCROLLER_SELECTOR) as HTMLElement | null;
|
||||
if (scroller) {
|
||||
const wasAtBottom =
|
||||
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLER_BOTTOM_EPSILON;
|
||||
if (revealed) {
|
||||
onReveal(null);
|
||||
if (wasAtBottom) {
|
||||
scheduleScrollToBottom(scroller);
|
||||
}
|
||||
} else {
|
||||
if (messageSummary.firstMessageId) {
|
||||
onReveal(messageSummary.firstMessageId);
|
||||
if (wasAtBottom) {
|
||||
scheduleScrollToBottom(scroller);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (revealed) {
|
||||
onReveal(null);
|
||||
} else {
|
||||
if (messageSummary.firstMessageId) {
|
||||
onReveal(messageSummary.firstMessageId);
|
||||
if (revealed) {
|
||||
onReveal(null);
|
||||
} else {
|
||||
if (messageSummary.firstMessageId) {
|
||||
onReveal(messageSummary.firstMessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[messageSummary.firstMessageId, onReveal, revealed, scheduleScrollToBottom],
|
||||
);
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container == null) {
|
||||
return;
|
||||
}
|
||||
}, [messageSummary.firstMessageId, onReveal, revealed, scheduleScrollToBottom]);
|
||||
const isInsideContent = (node: EventTarget | null): boolean =>
|
||||
node instanceof Node && contentRef.current?.contains(node) === true;
|
||||
const handleFocusIn = (event: FocusEvent) => {
|
||||
focusWithinContentRef.current = isInsideContent(event.target);
|
||||
};
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
if (isInsideContent(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
focusWithinContentRef.current = false;
|
||||
};
|
||||
container.addEventListener('focusin', handleFocusIn);
|
||||
container.addEventListener('focusout', handleFocusOut);
|
||||
return () => {
|
||||
container.removeEventListener('focusin', handleFocusIn);
|
||||
container.removeEventListener('focusout', handleFocusOut);
|
||||
};
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const wasRevealed = wasRevealedRef.current;
|
||||
wasRevealedRef.current = revealed;
|
||||
if (wasRevealed === revealed) {
|
||||
return;
|
||||
}
|
||||
const revealedByKeyboard = revealedByKeyboardRef.current;
|
||||
revealedByKeyboardRef.current = false;
|
||||
if (!KeyboardMode.keyboardModeEnabled || !MessageKeyboardFocusRollout.enabled) {
|
||||
focusWithinContentRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (revealed) {
|
||||
if (!revealedByKeyboard) {
|
||||
return;
|
||||
}
|
||||
const firstMessage = contentRef.current?.querySelector<HTMLElement>(getMessageSelector(channel.id));
|
||||
if (firstMessage == null) {
|
||||
return;
|
||||
}
|
||||
if (firstMessage.tabIndex < 0) {
|
||||
firstMessage.tabIndex = -1;
|
||||
}
|
||||
firstMessage.focus({preventScroll: true});
|
||||
return;
|
||||
}
|
||||
if (focusWithinContentRef.current) {
|
||||
focusWithinContentRef.current = false;
|
||||
toggleRef.current?.focus({preventScroll: true});
|
||||
}
|
||||
}, [channel.id, revealed]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollToBottomFrameRef.current != null) {
|
||||
@@ -247,9 +315,12 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
ref={toggleRef}
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
onClick={handleClick}
|
||||
aria-expanded={revealed}
|
||||
aria-controls={contentId}
|
||||
data-flx="channel.blocked-message-groups.toggle.click.button"
|
||||
>
|
||||
{variant === 'spammer'
|
||||
@@ -257,7 +328,13 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
: i18n._(BLOCKED_MESSAGES_DESCRIPTOR, {count: messageSummary.totalMessageCount})}
|
||||
</button>
|
||||
{revealed && (
|
||||
<div className={styles.content} data-blocked-messages data-flx="channel.blocked-message-groups.content">
|
||||
<div
|
||||
ref={contentRef}
|
||||
id={contentId}
|
||||
className={styles.content}
|
||||
data-blocked-messages
|
||||
data-flx="channel.blocked-message-groups.content"
|
||||
>
|
||||
{messageNodes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {MarkdownContext} from '@app/features/messaging/components/markdown/rende
|
||||
import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
import MessageFocus from '@app/features/messaging/state/MessageFocus';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import MessageReply from '@app/features/messaging/state/MessageReply';
|
||||
import {getMessageComponent} from '@app/features/messaging/utils/MessageComponentUtils';
|
||||
import {renderAstToPlaintext} from '@app/features/messaging/utils/markdown/Plaintext';
|
||||
@@ -483,6 +484,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
handlePopoutToggle,
|
||||
trackingEnabled: hoverTrackingEnabled,
|
||||
} = useMessageHoverState({messageRef, mobileLayoutEnabled, keyboardModeEnabled, contextMenuOpen});
|
||||
const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled;
|
||||
const handleFocusWithin = useCallback(() => {
|
||||
if (!keyboardModeEnabled) {
|
||||
return;
|
||||
@@ -512,6 +514,17 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
MessageFocus.clearFocusedMessageIfMatches(channel.id, message.id);
|
||||
}
|
||||
}, [channel, contextMenuOpen, isFocusedWithin, keyboardModeEnabled, message, message.id]);
|
||||
const isFocusedWithinRef = useRef(isFocusedWithin);
|
||||
isFocusedWithinRef.current = isFocusedWithin;
|
||||
const keyboardNavigationEnabledRef = useRef(keyboardNavigationEnabled);
|
||||
keyboardNavigationEnabledRef.current = keyboardNavigationEnabled;
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (keyboardNavigationEnabledRef.current && isFocusedWithinRef.current) {
|
||||
MessageFocus.clearFocusedMessageIfMatches(channel.id, message.id);
|
||||
}
|
||||
};
|
||||
}, [channel.id, message.id]);
|
||||
useEffect(() => {
|
||||
const wasEditing = wasEditingInPreviousUpdateRef.current;
|
||||
const justStartedEditing = !wasEditing && isEditing;
|
||||
@@ -689,7 +702,12 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FocusRing data-flx="channel.message.focus-ring">
|
||||
<FocusRing
|
||||
enabled={keyboardNavigationEnabled ? keyboardModeEnabled : undefined}
|
||||
within={keyboardNavigationEnabled}
|
||||
offset={keyboardNavigationEnabled ? -2 : undefined}
|
||||
data-flx="channel.message.focus-ring"
|
||||
>
|
||||
<div
|
||||
role="article"
|
||||
aria-label={messageAriaLabel}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type MessageFocusCandidate,
|
||||
resolveBottommostFocusableMessageId,
|
||||
} from '@app/features/channel/components/ChannelMessageFocusTarget';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function candidate(messageId: string, top: number, height: number): MessageFocusCandidate {
|
||||
return {messageId, top, bottom: top + height, height};
|
||||
}
|
||||
|
||||
const VIEWPORT_TOP = 0;
|
||||
const VIEWPORT_BOTTOM = 600;
|
||||
|
||||
describe('resolveBottommostFocusableMessageId', () => {
|
||||
it('picks the bottom-most mostly visible message', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('a', 10, 100), candidate('b', 150, 100), candidate('c', 300, 100)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('c');
|
||||
});
|
||||
|
||||
it('skips a message that is mostly scrolled past the bottom edge', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('a', 10, 100), candidate('b', 150, 100), candidate('c', 550, 100)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('b');
|
||||
});
|
||||
|
||||
it('falls back to the most visible message when nothing clears the visibility threshold', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('a', -900, 1000), candidate('b', 200, 1000)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('b');
|
||||
});
|
||||
|
||||
it('prefers the most visible message over the last one in the DOM below the viewport', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('tall', -100, 1000), candidate('offscreen', 2000, 50)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('tall');
|
||||
});
|
||||
|
||||
it('breaks a tie on visible height by taking the bottom-most message', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('a', -800, 900), candidate('b', 500, 900)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('b');
|
||||
});
|
||||
|
||||
it('falls back to the last message when nothing overlaps the viewport at all', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('a', 900, 100), candidate('b', 1200, 100)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('b');
|
||||
});
|
||||
|
||||
it('falls back to the last message when the newest one is hidden behind the composer', () => {
|
||||
const result = resolveBottommostFocusableMessageId([candidate('only', 400, 400)], VIEWPORT_TOP, VIEWPORT_BOTTOM);
|
||||
expect(result).toBe('only');
|
||||
});
|
||||
|
||||
it('ignores zero-height rows when scoring visibility', () => {
|
||||
expect(
|
||||
resolveBottommostFocusableMessageId(
|
||||
[candidate('a', 10, 100), candidate('b', 200, 0)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
),
|
||||
).toBe('a');
|
||||
});
|
||||
|
||||
it('returns null when there are no candidates', () => {
|
||||
expect(resolveBottommostFocusableMessageId([], VIEWPORT_TOP, VIEWPORT_BOTTOM)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
|
||||
import {type ChannelStreamItem, ChannelStreamType} from '@app/features/messaging/utils/MessageGroupingUtils';
|
||||
import {CHANNEL_MESSAGE_ID_PREFIX} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import {IS_DEV} from '@app/features/platform/types/Env';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import type {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
|
||||
@@ -126,7 +127,7 @@ export function renderChannelStream(props: RenderChannelStreamProps): Array<Reac
|
||||
flashKey={pendingFlashKey}
|
||||
showUnreadDividerSlots={true}
|
||||
unreadDividerBeforeMessageId={unreadDividerBeforeMessageId}
|
||||
idPrefix="chat-messages"
|
||||
idPrefix={CHANNEL_MESSAGE_ID_PREFIX}
|
||||
messageRowClassName={messageRowClassName}
|
||||
messageActionsClassName={messageActionsClassName}
|
||||
renderMessageActions={renderMessageActions}
|
||||
|
||||
@@ -33,12 +33,14 @@ import {
|
||||
} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
import MessageFocus from '@app/features/messaging/state/MessageFocus';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import MessagesState from '@app/features/messaging/state/MessagingMessages';
|
||||
import {
|
||||
type ChannelStreamItem,
|
||||
createChannelStream,
|
||||
getCollapsedMessageGroupKey,
|
||||
} from '@app/features/messaging/utils/MessageGroupingUtils';
|
||||
import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import LocalUserSpamOverride from '@app/features/moderation/state/LocalUserSpamOverride';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
@@ -398,7 +400,9 @@ export const Messages = observer(function Messages({
|
||||
const scroller = scrollManager.ref.current?.getViewportElement();
|
||||
const innerElement = scrollerInnerRef.current;
|
||||
if (!scroller || !innerElement) return;
|
||||
const messageElements = innerElement.querySelectorAll<HTMLElement>('[data-message-id]');
|
||||
const messageElements = innerElement.querySelectorAll<HTMLElement>(
|
||||
MessageKeyboardFocusRollout.enabled ? getMessageSelector(channel.id) : '[data-message-id]',
|
||||
);
|
||||
if (!messageElements.length) return;
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const candidates: Array<MessageFocusCandidate> = [];
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
} from '@app/features/messaging/state/MentionConfirmationStateMachine';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
import MessageEditMobile from '@app/features/messaging/state/MessageEditMobile';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import MessageReply from '@app/features/messaging/state/MessageReply';
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
|
||||
@@ -885,15 +886,17 @@ export const LexicalChannelTextareaContent = observer(
|
||||
onSubmit();
|
||||
}, [canSubmit, channel, hasAttachments, onSubmit]);
|
||||
const handleArrowUpEmpty = useCallback(() => {
|
||||
const claimsArrowUp = MessageKeyboardFocusRollout.enabled;
|
||||
if (KeyboardMode.keyboardModeEnabled) {
|
||||
ComponentBus.dispatch('FOCUS_BOTTOMMOST_MESSAGE', {channelId: channel.id});
|
||||
return;
|
||||
return claimsArrowUp;
|
||||
}
|
||||
const message = Messages.getLastEditableMessage(channel.id);
|
||||
if (!message) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
MessageCommands.startEdit(channel.id, message.id, message.content);
|
||||
return claimsArrowUp;
|
||||
}, [channel.id]);
|
||||
useTextareaDraftAndTyping({
|
||||
channelId: channel.id,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8');
|
||||
}
|
||||
|
||||
const messageCss = readSource('../../theme/styles/Message.module.css');
|
||||
const actionBarCss = readSource('./MessageActionBar.module.css');
|
||||
const focusRingCss = readSource('../../ui/focus_ring/FocusRing.module.css');
|
||||
const channelMessageSource = readSource('./ChannelMessage.tsx');
|
||||
const messageFocusRing = channelMessageSource.match(/<FocusRing\b[^>]*>/)?.[0] ?? '';
|
||||
|
||||
describe('message focus ring contract', () => {
|
||||
it('never draws a ring from a bare :focus selector on a message row', () => {
|
||||
expect(messageCss).not.toMatch(/\.message(Compact)?:focus(?!-visible)/);
|
||||
});
|
||||
|
||||
it('routes the row ring through the FocusRing framework', () => {
|
||||
expect(channelMessageSource).toMatch(/from '@app\/features\/ui\/focus_ring\/FocusRing'/);
|
||||
expect(messageFocusRing).not.toBe('');
|
||||
});
|
||||
|
||||
it('reads the ring arm from the keyboard navigation rollout', () => {
|
||||
expect(channelMessageSource).toMatch(/const keyboardNavigationEnabled = MessageKeyboardFocusRollout\.enabled;/);
|
||||
});
|
||||
|
||||
it('only enables the ring in keyboard navigation mode in the experiment arm', () => {
|
||||
expect(messageFocusRing).toMatch(/enabled=\{keyboardNavigationEnabled \? keyboardModeEnabled : undefined\}/);
|
||||
expect(messageFocusRing).toMatch(/within=\{keyboardNavigationEnabled\}/);
|
||||
});
|
||||
|
||||
it('insets the ring inside the row in the experiment arm and keeps the default geometry in control', () => {
|
||||
expect(focusRingCss).toMatch(/pointer-events:\s*none/);
|
||||
expect(messageFocusRing).toMatch(/offset=\{keyboardNavigationEnabled \? -2 : undefined\}/);
|
||||
});
|
||||
|
||||
it('stacks the ring below the action bar', () => {
|
||||
expect(actionBarCss).toMatch(/z-index:\s*var\(--z-index-elevated-1\)/);
|
||||
});
|
||||
|
||||
it('falls back to a system outline under forced colors', () => {
|
||||
const forcedColors = focusRingCss.match(/@media \(forced-colors: active\) \{\n\t\.focusRing \{([^{}]*)\}/)?.[1];
|
||||
expect(forcedColors).toBeDefined();
|
||||
expect(forcedColors).toMatch(/box-shadow:\s*none/);
|
||||
expect(forcedColors).toMatch(/outline-color:\s*Highlight/);
|
||||
});
|
||||
});
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
|
||||
import {act, useRef} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const {canFocusTextareaMock} = vi.hoisted(() => ({canFocusTextareaMock: vi.fn(() => true)}));
|
||||
|
||||
vi.mock('@app/features/platform/utils/InputFocusManager', () => ({
|
||||
canFocusTextarea: canFocusTextareaMock,
|
||||
}));
|
||||
|
||||
const {useChannelComposerDraftFocusRestore} = await import(
|
||||
'@app/features/channel/components/useChannelComposerDraftFocusRestore'
|
||||
);
|
||||
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let focusSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
interface HarnessProps {
|
||||
initialDraft: string;
|
||||
textareaInputDisabled?: boolean;
|
||||
inlineEditActive?: boolean;
|
||||
}
|
||||
|
||||
function Harness({initialDraft, textareaInputDisabled = false, inlineEditActive = false}: HarnessProps) {
|
||||
const handleRef = useRef<ComposerHandle | null>(null);
|
||||
handleRef.current = {focus: focusSpy} as unknown as ComposerHandle;
|
||||
const editableRef = useRef<HTMLDivElement | null>(null);
|
||||
useChannelComposerDraftFocusRestore({
|
||||
handleRef,
|
||||
editableRef,
|
||||
initialDraft,
|
||||
textareaInputDisabled,
|
||||
inlineEditActive,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={editableRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
data-flx="channel.use-channel-composer-draft-focus-restore-test.harness.div"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function render(props: HarnessProps): void {
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="channel.use-channel-composer-draft-focus-restore-test.harness" {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
focusSpy = vi.fn();
|
||||
canFocusTextareaMock.mockReturnValue(true);
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
canFocusTextareaMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useChannelComposerDraftFocusRestore', () => {
|
||||
test('focuses the composer when the channel is entered with a pending draft', () => {
|
||||
render({initialDraft: 'half written'});
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('leaves focus alone when there is no pending draft', () => {
|
||||
render({initialDraft: ''});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('yields to an active inline message edit', () => {
|
||||
render({initialDraft: 'half written', inlineEditActive: true});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does nothing when composer input is disabled', () => {
|
||||
render({initialDraft: 'half written', textareaInputDisabled: true});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('respects the shared focus guard that blocks mobile, modals and popouts', () => {
|
||||
canFocusTextareaMock.mockReturnValue(false);
|
||||
render({initialDraft: 'half written'});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('restores focus only on the channel mount, not on every re-render', () => {
|
||||
render({initialDraft: 'half written'});
|
||||
render({initialDraft: 'half written'});
|
||||
render({initialDraft: 'half written more'});
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -160,7 +160,7 @@ export interface LexicalComposerInputProps {
|
||||
onChange: (display: string, segments: Array<MentionSegment>, wire: string) => void;
|
||||
onCursorMove: () => void;
|
||||
onEnter?: () => void;
|
||||
onArrowUp: () => void;
|
||||
onArrowUp: () => boolean;
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
@@ -608,8 +608,9 @@ const ComposerInner = ({
|
||||
return false;
|
||||
}
|
||||
if (event != null && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
|
||||
if ($isComposerEmpty()) {
|
||||
cb.current.onArrowUp();
|
||||
if ($isComposerEmpty() && cb.current.onArrowUp()) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface LexicalRichInputProps {
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
const NOOP = (): void => {};
|
||||
const ARROW_UP_UNHANDLED = (): boolean => false;
|
||||
const SAFE_CHANNEL_TRIGGERS: Array<TriggerType> = ['emoji', 'mention', 'channel'];
|
||||
const SAFE_CONTEXT_FREE_TRIGGERS: Array<TriggerType> = ['emoji'];
|
||||
|
||||
@@ -245,7 +245,7 @@ export const LexicalRichInput = ({
|
||||
onChange={emitChange}
|
||||
onCursorMove={onCursorMove}
|
||||
onEnter={onSubmit == null ? undefined : handleEnter}
|
||||
onArrowUp={NOOP}
|
||||
onArrowUp={ARROW_UP_UNHANDLED}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {CHANNEL_MESSAGE_ID_PREFIX, findMessageElement} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import {act, createElement, type RefObject} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const keyboardModeMock = {keyboardModeEnabled: true};
|
||||
const messageFocusMock = {focusedMessageId: null as string | null};
|
||||
const rolloutMock = {enabled: true};
|
||||
|
||||
vi.mock('@app/features/ui/state/KeyboardMode', () => ({default: keyboardModeMock}));
|
||||
vi.mock('@app/features/messaging/state/MessageFocus', () => ({default: messageFocusMock}));
|
||||
vi.mock('@app/features/messaging/state/MessageKeyboardFocusRollout', () => ({default: rolloutMock}));
|
||||
|
||||
const {useMessageListKeyboardNavigation} = await import(
|
||||
'@app/features/messaging/hooks/useMessageListKeyboardNavigation'
|
||||
);
|
||||
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const CHANNEL_ID = '900000000000000001';
|
||||
|
||||
interface RowSpec {
|
||||
messageId: string;
|
||||
idPrefix: string;
|
||||
}
|
||||
|
||||
let viewport: HTMLElement;
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function mountRows(specs: ReadonlyArray<RowSpec>): void {
|
||||
for (const spec of specs) {
|
||||
const row = document.createElement('div');
|
||||
row.id = `${spec.idPrefix}-${CHANNEL_ID}-${spec.messageId}`;
|
||||
row.dataset.messageId = spec.messageId;
|
||||
row.dataset.channelId = CHANNEL_ID;
|
||||
row.tabIndex = -1;
|
||||
viewport.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
function focusedRowId(): string | null {
|
||||
const active = document.activeElement;
|
||||
return active instanceof HTMLElement ? (active.dataset.messageId ?? null) : null;
|
||||
}
|
||||
|
||||
function render(onFocusMessage?: (messageId: string) => void): void {
|
||||
const containerRef: RefObject<HTMLElement | null> = {current: viewport};
|
||||
function Harness(): null {
|
||||
useMessageListKeyboardNavigation({containerRef, channelId: CHANNEL_ID, onFocusMessage, allowWhenInactive: true});
|
||||
return null;
|
||||
}
|
||||
act(() => {
|
||||
root.render(createElement(Harness));
|
||||
});
|
||||
}
|
||||
|
||||
function pressArrow(key: 'ArrowUp' | 'ArrowDown'): void {
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true}));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
keyboardModeMock.keyboardModeEnabled = true;
|
||||
messageFocusMock.focusedMessageId = null;
|
||||
rolloutMock.enabled = true;
|
||||
viewport = document.createElement('div');
|
||||
document.body.append(viewport);
|
||||
viewport.addEventListener('focusin', (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement && target.dataset.messageId) {
|
||||
messageFocusMock.focusedMessageId = target.dataset.messageId;
|
||||
}
|
||||
});
|
||||
host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useMessageListKeyboardNavigation', () => {
|
||||
it('walks from the newest message up through a revealed blocked group', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: 'blocked-messages'},
|
||||
{messageId: '3', idPrefix: 'blocked-messages'},
|
||||
{messageId: '4', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('4');
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('3');
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('2');
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('1');
|
||||
});
|
||||
|
||||
it('walks back down out of a revealed blocked group', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: 'blocked-messages'},
|
||||
{messageId: '3', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
|
||||
pressArrow('ArrowDown');
|
||||
expect(focusedRowId()).toBe('1');
|
||||
pressArrow('ArrowDown');
|
||||
expect(focusedRowId()).toBe('2');
|
||||
pressArrow('ArrowDown');
|
||||
expect(focusedRowId()).toBe('3');
|
||||
});
|
||||
|
||||
it('keeps navigating when the focus delegate cannot resolve the target element', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: 'blocked-messages'},
|
||||
{messageId: '3', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
const onFocusMessage = vi.fn<(messageId: string) => void>();
|
||||
render(onFocusMessage);
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(onFocusMessage).toHaveBeenLastCalledWith('3');
|
||||
expect(focusedRowId()).toBe('3');
|
||||
pressArrow('ArrowUp');
|
||||
expect(onFocusMessage).toHaveBeenLastCalledWith('2');
|
||||
expect(focusedRowId()).toBe('2');
|
||||
});
|
||||
|
||||
it('leaves scrolling to the delegate when the delegate did move focus', () => {
|
||||
mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]);
|
||||
const scrollIntoView = vi.fn();
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('1');
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls the target into view itself when the delegate did nothing', () => {
|
||||
mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]);
|
||||
const scrollIntoView = vi.fn();
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
|
||||
render(vi.fn());
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(scrollIntoView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps navigating while a checkbox inside the list holds focus', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
viewport.append(checkbox);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
checkbox.focus();
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('2');
|
||||
});
|
||||
|
||||
it('stops navigating while a text input inside the list holds focus', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
const textInput = document.createElement('input');
|
||||
textInput.type = 'text';
|
||||
viewport.append(textInput);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
textInput.focus();
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBeNull();
|
||||
});
|
||||
|
||||
it('skips messages that a collapsed group has removed from the DOM', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '4', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('4');
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMessageListKeyboardNavigation control arm', () => {
|
||||
beforeEach(() => {
|
||||
rolloutMock.enabled = false;
|
||||
});
|
||||
|
||||
it('stops at a row the focus delegate cannot resolve', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: 'blocked-messages'},
|
||||
]);
|
||||
const onFocusMessage = vi.fn<(messageId: string) => void>();
|
||||
render(onFocusMessage);
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(onFocusMessage).toHaveBeenLastCalledWith('2');
|
||||
expect(focusedRowId()).toBeNull();
|
||||
});
|
||||
|
||||
it('never scrolls the target itself when a delegate is supplied', () => {
|
||||
mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]);
|
||||
const scrollIntoView = vi.fn();
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
|
||||
render(vi.fn());
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats a focused checkbox as editable and stops navigating', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
viewport.append(checkbox);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
checkbox.focus();
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isEditableElement} from '@app/features/app/keybindings/utils/EditableElement';
|
||||
import MessageFocus from '@app/features/messaging/state/MessageFocus';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import {type RefObject, useEffect} from 'react';
|
||||
@@ -52,17 +55,6 @@ const EMPTY_MESSAGE_NODES_SNAPSHOT: MessageNodesSnapshot = {
|
||||
selector: '',
|
||||
ts: 0,
|
||||
};
|
||||
const escapeSelectorValue = (value: string): string => {
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(value);
|
||||
}
|
||||
return value.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"');
|
||||
};
|
||||
const getMessageSelector = (channelId?: string, messageId?: string): string => {
|
||||
const channelSelector = channelId ? `[data-channel-id="${escapeSelectorValue(channelId)}"]` : '[data-channel-id]';
|
||||
const messageSelector = messageId ? `[data-message-id="${escapeSelectorValue(messageId)}"]` : '[data-message-id]';
|
||||
return `${channelSelector}${messageSelector}`;
|
||||
};
|
||||
|
||||
export function useMessageListKeyboardNavigation(options: MessageListKeyboardNavigationOptions): void {
|
||||
const {
|
||||
@@ -78,6 +70,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
allowWhenInactive = false,
|
||||
} = options;
|
||||
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
|
||||
const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled;
|
||||
useEffect(() => {
|
||||
if (!keyboardModeEnabled) return;
|
||||
let messageNodesCache: MessageNodesSnapshot = EMPTY_MESSAGE_NODES_SNAPSHOT;
|
||||
@@ -140,10 +133,19 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
};
|
||||
return messageNodesCache;
|
||||
};
|
||||
const hasFocusInside = (node: HTMLElement): boolean => {
|
||||
const activeElement = node.ownerDocument?.activeElement ?? document.activeElement;
|
||||
return activeElement != null && (activeElement === node || node.contains(activeElement));
|
||||
};
|
||||
const focusNode = (node: HTMLElement, messageId: string) => {
|
||||
if (onFocusMessage) {
|
||||
onFocusMessage(messageId);
|
||||
return;
|
||||
if (!keyboardNavigationEnabled || hasFocusInside(node)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (keyboardNavigationEnabled && node.tabIndex < 0) {
|
||||
node.tabIndex = -1;
|
||||
}
|
||||
node.focus({preventScroll: true});
|
||||
node.scrollIntoView({block: 'nearest', inline: 'nearest'});
|
||||
@@ -197,7 +199,10 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!keyboardModeEnabled) return;
|
||||
if (isEditableTarget(document.activeElement)) return;
|
||||
const activeElementIsEditable = keyboardNavigationEnabled
|
||||
? isEditableElement(document.activeElement)
|
||||
: isEditableTarget(document.activeElement);
|
||||
if (activeElementIsEditable) return;
|
||||
const delta = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0;
|
||||
const isNavigationKey = delta !== 0;
|
||||
if (isNavigationKey && hasShortcutModifier(event)) return;
|
||||
@@ -226,6 +231,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
};
|
||||
}, [
|
||||
keyboardModeEnabled,
|
||||
keyboardNavigationEnabled,
|
||||
containerRef,
|
||||
channelId,
|
||||
onFocusMessage,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type ExperimentAssignmentsResponse,
|
||||
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {
|
||||
INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT,
|
||||
type MessageKeyboardFocusAssignmentResponse,
|
||||
} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
import {runInAction} from 'mobx';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/platform/utils/AppLogger', () => ({
|
||||
Logger: class {
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/platform/transport/RestTransport', () => ({
|
||||
http: {get: vi.fn(), post: vi.fn()},
|
||||
}));
|
||||
|
||||
const {ExperimentAssignments} = await import('@app/features/experiment/state/ExperimentAssignments');
|
||||
const {MessageKeyboardFocusRollout} = await import('@app/features/messaging/state/MessageKeyboardFocusRollout');
|
||||
|
||||
function publish(assignment: MessageKeyboardFocusAssignmentResponse): void {
|
||||
const response: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: 300,
|
||||
poll_jitter_percent: 15,
|
||||
assignments: {message_keyboard_focus: assignment},
|
||||
};
|
||||
runInAction(() => {
|
||||
ExperimentAssignments.response = response;
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
runInAction(() => {
|
||||
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageKeyboardFocusRollout', () => {
|
||||
it('reads the inert assignment out of the inert envelope', () => {
|
||||
expect(MessageKeyboardFocusRollout.assignment).toBe(INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT);
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('stays on the control arm while the rollout is disabled', () => {
|
||||
publish({enabled: false, config_version: 4, user_targeted: false, source: null});
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('stays on the control arm for an account the rollout did not target', () => {
|
||||
publish({enabled: true, config_version: 4, user_targeted: false, source: null});
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('moves to the experiment arm for a targeted account', () => {
|
||||
publish({enabled: true, config_version: 4, user_targeted: true, source: 'canary'});
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('follows the envelope back to the control arm when the store is reset', () => {
|
||||
publish({enabled: true, config_version: 4, user_targeted: true, source: 'user_rule'});
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(true);
|
||||
ExperimentAssignments.reset();
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import {readMessageKeyboardFocusAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import type {MessageKeyboardFocusAssignmentResponse} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
|
||||
class MessageKeyboardFocusRolloutSelector {
|
||||
get assignment(): MessageKeyboardFocusAssignmentResponse {
|
||||
return readMessageKeyboardFocusAssignment(ExperimentAssignments.response);
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
const assignment = this.assignment;
|
||||
return assignment.enabled && assignment.user_targeted;
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageKeyboardFocusRollout = new MessageKeyboardFocusRolloutSelector();
|
||||
|
||||
export default MessageKeyboardFocusRollout;
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
CHANNEL_MESSAGE_ID_PREFIX,
|
||||
findMessageElement,
|
||||
getMessageSelector,
|
||||
} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const CHANNEL_ID = '900000000000000001';
|
||||
|
||||
function messageRow(messageId: string, idPrefix: string): HTMLElement {
|
||||
const row = document.createElement('div');
|
||||
row.id = `${idPrefix}-${CHANNEL_ID}-${messageId}`;
|
||||
row.dataset.messageId = messageId;
|
||||
row.dataset.channelId = CHANNEL_ID;
|
||||
return row;
|
||||
}
|
||||
|
||||
function viewport(): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
describe('MessageNodeSelectors', () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('resolves a normal stream row through the id fast path', () => {
|
||||
const scroller = viewport();
|
||||
const row = messageRow('1', CHANNEL_MESSAGE_ID_PREFIX);
|
||||
scroller.append(row);
|
||||
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '1')).toBe(row);
|
||||
});
|
||||
|
||||
it('resolves a revealed blocked group row that carries a different id prefix', () => {
|
||||
const scroller = viewport();
|
||||
scroller.append(messageRow('1', CHANNEL_MESSAGE_ID_PREFIX));
|
||||
const blockedRow = messageRow('2', 'blocked-messages');
|
||||
scroller.append(blockedRow);
|
||||
|
||||
expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-2`)).toBeNull();
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '2')).toBe(blockedRow);
|
||||
});
|
||||
|
||||
it('resolves a revealed spammer group row', () => {
|
||||
const scroller = viewport();
|
||||
const spammerRow = messageRow('3', 'spammer-messages');
|
||||
scroller.append(spammerRow);
|
||||
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '3')).toBe(spammerRow);
|
||||
});
|
||||
|
||||
it('falls back to the viewport-scoped lookup and ignores rows outside it', () => {
|
||||
const scroller = viewport();
|
||||
const searchPanel = viewport();
|
||||
searchPanel.append(messageRow('4', 'blocked-messages'));
|
||||
|
||||
expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-4`)).toBeNull();
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '4')).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a row through the document-wide id lookup even when it sits outside the viewport', () => {
|
||||
const scroller = viewport();
|
||||
const popout = viewport();
|
||||
const inPopout = messageRow('8', CHANNEL_MESSAGE_ID_PREFIX);
|
||||
popout.append(inPopout);
|
||||
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '8')).toBe(inPopout);
|
||||
});
|
||||
|
||||
it('prefers the row inside the viewport when neither row carries the canonical id', () => {
|
||||
const scroller = viewport();
|
||||
const inStream = messageRow('5', 'blocked-messages');
|
||||
scroller.append(inStream);
|
||||
const searchPanel = viewport();
|
||||
const inSearch = messageRow('5', 'search-messages');
|
||||
searchPanel.append(inSearch);
|
||||
|
||||
expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-5`)).toBeNull();
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '5')).toBe(inStream);
|
||||
});
|
||||
|
||||
it('does not resolve a row belonging to another channel', () => {
|
||||
const scroller = viewport();
|
||||
const foreign = document.createElement('div');
|
||||
foreign.dataset.messageId = '6';
|
||||
foreign.dataset.channelId = '900000000000000002';
|
||||
scroller.append(foreign);
|
||||
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '6')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires both data attributes so message group row wrappers are not candidates', () => {
|
||||
const scroller = viewport();
|
||||
const groupWrapper = document.createElement('div');
|
||||
groupWrapper.dataset.messageId = '7';
|
||||
const row = messageRow('7', CHANNEL_MESSAGE_ID_PREFIX);
|
||||
groupWrapper.append(row);
|
||||
scroller.append(groupWrapper);
|
||||
|
||||
const matches = scroller.querySelectorAll<HTMLElement>(getMessageSelector(CHANNEL_ID));
|
||||
expect(Array.from(matches)).toEqual([row]);
|
||||
});
|
||||
|
||||
it('leaves snowflake ids untouched instead of emitting identifier escapes', () => {
|
||||
expect(getMessageSelector(CHANNEL_ID, '900000000000000009')).toBe(
|
||||
'[data-channel-id="900000000000000001"][data-message-id="900000000000000009"]',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes quotes and backslashes so an id can never break out of the attribute selector', () => {
|
||||
expect(getMessageSelector(undefined, 'a"]b')).toBe('[data-channel-id][data-message-id="a\\"]b"]');
|
||||
expect(getMessageSelector(undefined, 'a\\b')).toBe('[data-channel-id][data-message-id="a\\\\b"]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const CHANNEL_MESSAGE_ID_PREFIX = 'chat-messages';
|
||||
|
||||
export const escapeSelectorValue = (value: string): string =>
|
||||
value.replace(/[\\"]/gu, '\\$&').replace(/[\n\r\f]/gu, (char) => `\\${char.charCodeAt(0).toString(16)} `);
|
||||
|
||||
export const getMessageSelector = (channelId?: string, messageId?: string): string => {
|
||||
const channelSelector = channelId ? `[data-channel-id="${escapeSelectorValue(channelId)}"]` : '[data-channel-id]';
|
||||
const messageSelector = messageId ? `[data-message-id="${escapeSelectorValue(messageId)}"]` : '[data-message-id]';
|
||||
return `${channelSelector}${messageSelector}`;
|
||||
};
|
||||
|
||||
export const findMessageElement = (
|
||||
doc: Document | null | undefined,
|
||||
viewport: HTMLElement | null | undefined,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
): HTMLElement | null => {
|
||||
const byId = doc?.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${channelId}-${messageId}`) ?? null;
|
||||
if (byId != null) {
|
||||
return byId as HTMLElement;
|
||||
}
|
||||
return viewport?.querySelector<HTMLElement>(getMessageSelector(channelId, messageId)) ?? null;
|
||||
};
|
||||
@@ -4,7 +4,9 @@ import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||
import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
import {CHANNEL_MESSAGE_ID_PREFIX, findMessageElement} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
||||
import Navigation from '@app/features/navigation/state/Navigation';
|
||||
import {evaluateScrollPinning, type ScrollPinResult} from '@app/features/platform/utils/ScrollPosition';
|
||||
@@ -210,10 +212,12 @@ export class ScrollManager {
|
||||
|
||||
layoutGetElementFromMessageId(messageId: string): HTMLElement | null {
|
||||
const doc = this.scrollGetDocument();
|
||||
const {channel} = this.props;
|
||||
if (!doc) return null;
|
||||
const elementId = `chat-messages-${channel.id}-${messageId}`;
|
||||
return doc.getElementById(elementId) as HTMLElement | null;
|
||||
const {channel} = this.props;
|
||||
if (!MessageKeyboardFocusRollout.enabled) {
|
||||
return doc.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${channel.id}-${messageId}`) as HTMLElement | null;
|
||||
}
|
||||
return findMessageElement(doc, this.ref.current?.getViewportElement(), channel.id, messageId);
|
||||
}
|
||||
|
||||
private layoutGetContainerLayout(container: HTMLElement): ContainerLayout {
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import FocusRingContext, {type FocusRingContextManager} from '@app/features/ui/focus_ring/FocusRingContext';
|
||||
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
|
||||
import FocusRingScope from '@app/features/ui/focus_ring/FocusRingScope';
|
||||
import {act, useContext, useRef} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const RING_SELECTOR = '[data-flx="ui.focus-ring.focus-ring-scope.ring.focus-ring"]';
|
||||
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type Slot = 'primary' | 'secondary';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let unmounted = false;
|
||||
const ringContexts: Partial<Record<Slot, FocusRingContextManager>> = {};
|
||||
const targets: Partial<Record<Slot, HTMLDivElement>> = {};
|
||||
|
||||
function Capture({slot = 'primary'}: {slot?: Slot}) {
|
||||
ringContexts[slot] = useContext(FocusRingContext);
|
||||
return (
|
||||
<div
|
||||
ref={(element) => {
|
||||
if (element != null) targets[slot] = element;
|
||||
}}
|
||||
data-flx="ui.focus-ring.focus-ring-scope-test.capture.div"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness({revision = 0}: {revision?: number}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<div ref={containerRef} data-revision={revision} data-flx="ui.focus-ring.focus-ring-scope-test.harness.div">
|
||||
<FocusRingScope
|
||||
containerRef={containerRef}
|
||||
data-flx="ui.focus-ring.focus-ring-scope-test.harness.focus-ring-scope"
|
||||
>
|
||||
<Capture data-flx="ui.focus-ring.focus-ring-scope-test.harness.capture" />
|
||||
</FocusRingScope>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopePair() {
|
||||
const primaryRef = useRef<HTMLDivElement>(null);
|
||||
const secondaryRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<>
|
||||
<div ref={primaryRef} data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.div">
|
||||
<FocusRingScope
|
||||
containerRef={primaryRef}
|
||||
data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.focus-ring-scope"
|
||||
>
|
||||
<Capture slot="primary" data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.capture" />
|
||||
</FocusRingScope>
|
||||
</div>
|
||||
<div ref={secondaryRef} data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.div--2">
|
||||
<FocusRingScope
|
||||
containerRef={secondaryRef}
|
||||
data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.focus-ring-scope--2"
|
||||
>
|
||||
<Capture slot="secondary" data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.capture--2" />
|
||||
</FocusRingScope>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function rings(): Array<Element> {
|
||||
return Array.from(container.querySelectorAll(RING_SELECTOR));
|
||||
}
|
||||
|
||||
function ring(): Element | null {
|
||||
return container.querySelector(RING_SELECTOR);
|
||||
}
|
||||
|
||||
function requireRing(): HTMLElement {
|
||||
const element = ring();
|
||||
if (element == null) throw new Error('The focus ring is not painted');
|
||||
return element as HTMLElement;
|
||||
}
|
||||
|
||||
function requireRingContext(slot: Slot = 'primary'): FocusRingContextManager {
|
||||
const value = ringContexts[slot];
|
||||
if (value == null) throw new Error(`The focus ring context for ${slot} never rendered`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireTarget(slot: Slot = 'primary'): HTMLDivElement {
|
||||
const value = targets[slot];
|
||||
if (value == null) throw new Error(`The focus ring target for ${slot} never rendered`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireScopeContainer(): HTMLElement {
|
||||
const element = container.firstElementChild;
|
||||
if (element == null) throw new Error('The scope container never rendered');
|
||||
return element as HTMLElement;
|
||||
}
|
||||
|
||||
function domRect(top: number, left: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function stubBoundingRect(element: Element, read: () => DOMRect) {
|
||||
Object.defineProperty(element, 'getBoundingClientRect', {value: read, configurable: true});
|
||||
}
|
||||
|
||||
function unmountRoot() {
|
||||
if (unmounted) return;
|
||||
unmounted = true;
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete ringContexts.primary;
|
||||
delete ringContexts.secondary;
|
||||
delete targets.primary;
|
||||
delete targets.secondary;
|
||||
unmounted = false;
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unmountRoot();
|
||||
container.remove();
|
||||
FocusRingManager.setRingsEnabled(true);
|
||||
});
|
||||
|
||||
describe('FocusRingScope', () => {
|
||||
test('paints the ring once rings are enabled, without a second focus event', () => {
|
||||
FocusRingManager.setRingsEnabled(false);
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness" />);
|
||||
});
|
||||
act(() => {
|
||||
requireRingContext().showForElement(requireTarget());
|
||||
});
|
||||
expect(ring()).toBeNull();
|
||||
act(() => {
|
||||
FocusRingManager.setRingsEnabled(true);
|
||||
});
|
||||
expect(ring()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('restores the ring for a target that never blurred while rings were disabled', () => {
|
||||
FocusRingManager.setRingsEnabled(true);
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--2" />);
|
||||
});
|
||||
act(() => {
|
||||
requireRingContext().showForElement(requireTarget());
|
||||
});
|
||||
expect(ring()).not.toBeNull();
|
||||
act(() => {
|
||||
FocusRingManager.setRingsEnabled(false);
|
||||
});
|
||||
expect(ring()).toBeNull();
|
||||
act(() => {
|
||||
FocusRingManager.setRingsEnabled(true);
|
||||
});
|
||||
expect(ring()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the ring hidden when nothing is showing', () => {
|
||||
FocusRingManager.setRingsEnabled(false);
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--3" />);
|
||||
});
|
||||
act(() => {
|
||||
FocusRingManager.setRingsEnabled(true);
|
||||
});
|
||||
expect(ring()).toBeNull();
|
||||
});
|
||||
|
||||
test('paints the ring on show and removes it on hide', () => {
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--4" />);
|
||||
});
|
||||
expect(ring()).toBeNull();
|
||||
act(() => {
|
||||
requireRingContext().showForElement(requireTarget());
|
||||
});
|
||||
expect(ring()).not.toBeNull();
|
||||
act(() => {
|
||||
requireRingContext().hide();
|
||||
});
|
||||
expect(ring()).toBeNull();
|
||||
});
|
||||
|
||||
test('repositions the ring when a parent render moves a target that did not resize', () => {
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--5" />);
|
||||
});
|
||||
stubBoundingRect(requireScopeContainer(), () => domRect(0, 0, 800, 600));
|
||||
let targetTop = 200;
|
||||
stubBoundingRect(requireTarget(), () => domRect(targetTop, 0, 300, 40));
|
||||
act(() => {
|
||||
requireRingContext().showForElement(requireTarget());
|
||||
});
|
||||
expect(requireRing().style.top).toBe('200px');
|
||||
targetTop = 320;
|
||||
act(() => {
|
||||
root.render(<Harness revision={1} data-flx="ui.focus-ring.focus-ring-scope-test.harness--6" />);
|
||||
});
|
||||
expect(requireRing().style.top).toBe('320px');
|
||||
});
|
||||
|
||||
test('repositions the ring when the target itself resizes, and stops observing on unmount', () => {
|
||||
const realResizeObserver = globalThis.ResizeObserver;
|
||||
const realRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const realCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
let notifyResize: (() => void) | null = null;
|
||||
let disconnectCount = 0;
|
||||
class StubResizeObserver {
|
||||
constructor(callback: () => void) {
|
||||
notifyResize = callback;
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {
|
||||
disconnectCount += 1;
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver = StubResizeObserver as unknown as typeof ResizeObserver;
|
||||
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}) as typeof requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = (() => undefined) as typeof cancelAnimationFrame;
|
||||
try {
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--7" />);
|
||||
});
|
||||
stubBoundingRect(requireScopeContainer(), () => domRect(0, 0, 800, 600));
|
||||
let targetHeight = 40;
|
||||
stubBoundingRect(requireTarget(), () => domRect(100, 0, 300, targetHeight));
|
||||
act(() => {
|
||||
requireRingContext().showForElement(requireTarget());
|
||||
});
|
||||
expect(requireRing().style.height).toBe('40px');
|
||||
targetHeight = 90;
|
||||
act(() => {
|
||||
notifyResize?.();
|
||||
});
|
||||
expect(requireRing().style.height).toBe('90px');
|
||||
unmountRoot();
|
||||
expect(disconnectCount).toBe(1);
|
||||
} finally {
|
||||
globalThis.ResizeObserver = realResizeObserver;
|
||||
globalThis.requestAnimationFrame = realRequestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = realCancelAnimationFrame;
|
||||
}
|
||||
});
|
||||
|
||||
test('drops the ring from the previous scope when another scope takes focus', () => {
|
||||
act(() => {
|
||||
root.render(<ScopePair data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair" />);
|
||||
});
|
||||
act(() => {
|
||||
requireRingContext('primary').showForElement(requireTarget('primary'));
|
||||
});
|
||||
expect(rings()).toHaveLength(1);
|
||||
act(() => {
|
||||
requireRingContext('secondary').showForElement(requireTarget('secondary'));
|
||||
});
|
||||
expect(rings()).toHaveLength(1);
|
||||
expect(requireRingContext('primary').visible).toBe(false);
|
||||
expect(requireRingContext('secondary').visible).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user