fix(app): show hover highlight and inset the focus ring (#2778)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 20:51:59 +02:00
1 parent 5b6949170f
commit 550e6b05a1
5 files changed
+110 -53

No files matched your search

@@ -223,6 +223,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
const [isFocusedWithin, setIsFocusedWithin] = useState(false);
const [mobileLongPressLinkUrl, setMobileLongPressLinkUrl] = useState<string | undefined>(undefined);
const messageRef = useRef<HTMLDivElement | null>(null);
const focusRingAnchorRef = useRef<HTMLDivElement | null>(null);
const disableContextMenuTracking = behaviorOverrides?.disableContextMenuTracking ?? false;
const trackedContextMenuOpen = useContextMenuHoverState(messageRef, !disableContextMenuTracking);
const contextMenuOpen = disableContextMenuTracking
@@ -706,6 +707,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
enabled={keyboardNavigationEnabled ? keyboardModeEnabled : undefined}
within={keyboardNavigationEnabled}
offset={keyboardNavigationEnabled ? -2 : undefined}
ringTarget={keyboardNavigationEnabled ? focusRingAnchorRef : undefined}
data-flx="channel.message.focus-ring"
>
<div
@@ -752,6 +754,14 @@ export const Message: React.FC<MessageProps> = observer((props) => {
style={articleStyle}
data-flx="channel.message.article.alt-click"
>
{keyboardNavigationEnabled && (
<div
ref={focusRingAnchorRef}
aria-hidden={true}
className={styles.focusRingAnchor}
data-flx="channel.message.focus-ring-anchor"
/>
)}
{messageComponent}
{shouldMountActionBar &&
(previewMode ? (
@@ -14,6 +14,22 @@ const focusRingCss = readSource('../../ui/focus_ring/FocusRing.module.css');
const channelMessageSource = readSource('./ChannelMessage.tsx');
const messageFocusRing = channelMessageSource.match(/<FocusRing\b[^>]*>/)?.[0] ?? '';
interface CssRule {
readonly selector: string;
readonly body: string;
}
function parseCssRules(css: string): Array<CssRule> {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
const rules: Array<CssRule> = [];
for (const match of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
rules.push({selector: match[1].replace(/\s+/g, ' ').trim(), body: match[2].trim()});
}
return rules;
}
const messageRules = parseCssRules(messageCss);
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)/);
@@ -40,6 +56,29 @@ describe('message focus ring contract', () => {
it('stacks the ring below the action bar', () => {
expect(actionBarCss).toMatch(/z-index:\s*var\(--z-index-elevated-1\)/);
expect(messageFocusRing).not.toMatch(/zIndex=/);
});
it('measures the ring from an anchor inset by the row bleed in the experiment arm', () => {
expect(messageFocusRing).toMatch(/ringTarget=\{keyboardNavigationEnabled \? focusRingAnchorRef : undefined\}/);
expect(channelMessageSource).toMatch(/className=\{styles\.focusRingAnchor\}/);
const anchor = messageRules.find((rule) => rule.selector === '.focusRingAnchor');
expect(anchor).toBeDefined();
expect(anchor?.body).toMatch(/position:\s*absolute/);
expect(anchor?.body).toMatch(/inset-inline:\s*var\(--message-inline-bleed/);
expect(anchor?.body).toMatch(/pointer-events:\s*none/);
});
it('derives every message row bleed from the shared bleed variable', () => {
const bleedingRows = messageRules.filter(
(rule) => /^\.message(Compact|Preview)?$/.test(rule.selector) && /margin-inline\s*:/.test(rule.body),
);
expect(bleedingRows.length).toBeGreaterThan(0);
for (const rule of bleedingRows) {
const margin = rule.body.match(/margin-inline:\s*([^;]+)/)?.[1]?.trim();
expect(margin).toBeDefined();
expect(margin === '0' || margin === 'calc(-1 * var(--message-inline-bleed))').toBe(true);
}
});
it('falls back to a system outline under forced colors', () => {
@@ -78,6 +78,21 @@ describe('message hover style contract', () => {
}
});
it('paints the row highlight in both arms because the class is driven by React', () => {
const highlightRules = parseCssRules(messageCss).filter(
(rule) =>
rule.selector.includes('.messageHovered') &&
!rule.selector.includes(':hover') &&
!/\.buttons\b/.test(rule.selector) &&
/(background-color|opacity):/.test(rule.body),
);
expect(highlightRules.length).toBeGreaterThan(0);
for (const rule of highlightRules) {
expect(rule.selector).not.toContain(CONTROL_GATE);
expect(rule.selector).not.toContain(TREATMENT_GATE);
}
});
it('derives the row highlight and the action bar from the same hover state', () => {
const highlightSource = channelMessageSource.match(/(\w+) && !isPreview && styles\.messageHovered/)?.[1];
const actionBarSourceState = channelMessageSource.match(/const isActionBarActive = (\w+) \|\|/)?.[1];
@@ -38,7 +38,8 @@
padding-block: var(--message-spacing-y);
padding-inline-end: var(--chat-horizontal-padding, var(--chat-horizontal-padding-default));
padding-inline-start: 0;
margin-inline: calc(-1 * var(--message-mobile-margin));
--message-inline-bleed: var(--message-mobile-margin);
margin-inline: calc(-1 * var(--message-inline-bleed));
}
.messagePreviewSpacing {
@@ -53,6 +54,13 @@
--message-hovered: 1;
}
.focusRingAnchor {
position: absolute;
inset-block: 0;
inset-inline: var(--message-inline-bleed, 0px);
pointer-events: none;
}
.message::selection,
.messageCompact::selection,
.message *::selection,
@@ -62,7 +70,8 @@
@media (min-width: 768px) {
.message {
margin-inline: calc(-1 * var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)));
--message-inline-bleed: var(--chat-horizontal-padding, var(--chat-horizontal-padding-default));
margin-inline: calc(-1 * var(--message-inline-bleed));
padding-inline-end: calc(
var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)) +
var(--message-action-bar-offset)
@@ -70,39 +79,24 @@
}
.messagePreview {
--message-inline-bleed: 0px;
margin-inline: 0;
padding-inline-end: var(--chat-horizontal-padding, var(--chat-horizontal-padding-default));
}
}
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.message.messageHovered:not(.messageMentioned):not(.messageReplying):not(.messageHighlight):not(.messagePreview),
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.messageCompact.messageHovered:not(.messageMentioned):not(.messageReplying):not(.messageHighlight):not(
.messagePreview
) {
background-color: var(--background-modifier-hover);
}
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.message.messageMentioned.messageHovered,
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.messageCompact.messageMentioned.messageHovered {
background-color: var(--message-mention-bg-hover);
}
@@ -114,29 +108,13 @@
background-color: var(--message-mention-bg);
}
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.message.messageReplying.messageHovered:not(.messageMentioned),
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.message.messageHighlight.messageHovered:not(.messageMentioned),
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.messageCompact.messageReplying.messageHovered:not(.messageMentioned),
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.messageCompact.messageHighlight.messageHovered:not(.messageMentioned) {
background-color: var(--message-reply-bg);
}
@@ -218,7 +196,8 @@
padding-inline-start: 0;
padding-inline-end: var(--message-compact-horizontal-padding);
text-indent: 0;
margin-inline: calc(-1 * var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)));
--message-inline-bleed: var(--chat-horizontal-padding, var(--chat-horizontal-padding-default));
margin-inline: calc(-1 * var(--message-inline-bleed));
}
@media (min-width: 768px) {
@@ -618,11 +597,7 @@
transform: translateX(0.5rem);
}
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.message.messageHovered
.messageTimestampHover {
opacity: 1;
@@ -698,11 +673,7 @@
margin-inline-end: 0;
}
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.messageCompact.messageHovered
.messageTimestampCompactHover {
opacity: 1;
@@ -287,4 +287,26 @@ describe('FocusRingScope', () => {
expect(requireRingContext('primary').visible).toBe(false);
expect(requireRingContext('secondary').visible).toBe(true);
});
test('keeps a positively inset ring inside the scope container the row bleeds out of', () => {
act(() => {
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--inset" />);
});
const scopeContainer = requireScopeContainer();
stubBoundingRect(scopeContainer, () => domRect(0, 0, 500, 400));
stubBoundingRect(requireTarget(), () => domRect(100, -16, 532, 40));
act(() => {
requireRingContext().showForElement(requireTarget(), {offset: -2});
});
const bleeding = requireRing();
expect(Number.parseFloat(bleeding.style.left)).toBeLessThan(0);
act(() => {
requireRingContext().showForElement(requireTarget(), {offset: {top: -2, bottom: -2, left: 18, right: 18}});
});
const inset = requireRing();
const left = Number.parseFloat(inset.style.left);
const width = Number.parseFloat(inset.style.width);
expect(left).toBeGreaterThan(0);
expect(left + width).toBeLessThan(500);
});
});