Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,9 @@ function ChannelWorkspace({
scope={scope}
channelId={current.id}
channelName={current.name}
autoFocus={
!current.readOnly && !requestedMessage && !requestedThread
}
onOpenLink={openLink}
canOpenLink={canOpenLink}
label={
Expand Down
58 changes: 58 additions & 0 deletions src/features/messages/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,64 @@ function mount(
};
}

it("autofocuses each selected conversation once without stealing focus on updates", () => {
const h = mount({ autoFocus: true });
expect(h.input()).toHaveFocus();
const other = document.createElement("button");
document.body.append(other);
try {
other.focus();
h.retarget({ channelName: "Renamed", autoFocus: false });
h.retarget({ autoFocus: true });
expect(other).toHaveFocus();
h.retarget({ channelId: "another-channel" });
expect(h.input()).toHaveFocus();
h.retarget({ channelId: "keyboard-navigation" });
expect(h.input()).toHaveFocus();
} finally {
other.remove();
}
});

it("lets an explicit focus restoration in the mount commit win", () => {
const h = mount();
h.unmount();
const target = document.createElement("button");
document.body.append(target);
function RestoreFocus() {
useLayoutEffect(() => target.focus(), []);
return null;
}
try {
render(
<>
<MessageComposer
session={h.session}
scope="scope"
channelId="channel"
channelName="General"
autoFocus
/>
<RestoreFocus />
</>,
);
expect(target).toHaveFocus();
} finally {
target.remove();
}
});

it("leaves focus alone unless an enabled composer opts into mount focus", () => {
const h = mount();
expect(h.input()).not.toHaveFocus();
h.retarget({
channelId: "disabled-channel",
disabled: true,
autoFocus: true,
});
expect(h.input()).not.toHaveFocus();
});

it("keeps unpublished completions invisible but lets Escape revoke pending work", () => {
const h = mount();
const input = h.input();
Expand Down
24 changes: 24 additions & 0 deletions src/features/messages/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export type MessageComposerProps = {
replyContext?: ReactNode;
mediaTimeSeconds?: number;
clearMediaTime?(): void;
/** Focus once when this conversation mounts, not when overlays close. */
autoFocus?: boolean;
focusRequest?: number;
hideMediaTimeIndicator?: boolean;
disabled?: boolean;
Expand Down Expand Up @@ -138,6 +140,7 @@ function Composer({
replyContext,
mediaTimeSeconds,
clearMediaTime,
autoFocus = false,
focusRequest,
hideMediaTimeIndicator = false,
disabled: requestedDisabled = false,
Expand Down Expand Up @@ -202,6 +205,27 @@ function Composer({
const valueRef = useRef(value);
const caret = useRef<number | undefined>(undefined);
const input = useRef<ComposerInputElement>(null);
const focusOnMount = useRef(
autoFocus && !disabled && typeof document !== "undefined"
? document.activeElement
: undefined,
);
useEffect(() => {
// A navigation/dialog owner may restore focus during this commit. Let that
// explicit handoff win over the conversation's default initial focus.
const previous = focusOnMount.current;
if (
previous &&
(previous === document.activeElement ||
(!previous.isConnected && document.activeElement === document.body))
) {
const editor = input.current;
if (!editor) return;
const end = editor.value.length;
editor.setSelectionRange(end, end);
editor.focus();
}
}, []);
const nonmembers = useNonmemberMentions(session, channelId, () =>
input.current?.focus(),
);
Expand Down
41 changes: 41 additions & 0 deletions tests/browser/composer-focus.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { test, expect } from "./fixture.mjs";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required DCO sign-off

The reviewed commit's raw message has no Signed-off-by trailer, so it does not satisfy the repository's per-commit DCO requirement and the DCO check will reject this exact squashed commit even though the message reports a pass for the earlier f6c5e2e5 head. Recreate the commit with a sign-off from its actual author and verify the check at the new head.

AGENTS.md reference: AGENTS.md:L153-L160

Useful? React with 👍 / 👎.

import { open } from "./timeline.mjs";

test.use({ productionBroker: true, dmLabels: true });

// Browser boundary: real sidebar navigation must hand focus to the mounted
// ProseMirror editor, including after a warm return; typing must need no click.
test("selecting a channel or DM focuses its composer and retains drafts", async ({
page,
app,
}) => {
await open(page, app);
const sidebar = page.getByRole("navigation", { name: "Subscribed channels" });
const conversation = page.getByRole("article", { name: "Conversation" });
const input = conversation.getByRole("textbox");
for (const [index, name] of [
"Beta",
"Alice Fixture",
"Beta",
"Alice Fixture",
].entries()) {
await sidebar.getByRole("button", { name, exact: true }).click();
await expect(input).toBeFocused();
if (index < 2) {
await page.keyboard.type(`Draft for ${name}`);
await expect(input).toHaveText(`Draft for ${name}`);
} else {
await expect(input).toHaveText(`Draft for ${name}`);
await page.keyboard.type(" continued");
await expect(input).toHaveText(`Draft for ${name} continued`);
}
}
const settings = page.getByRole("button", {
name: "Channel settings",
exact: true,
});
await settings.click();
await expect(
page.getByRole("button", { name: "Close channel settings" }),
).toBeFocused();
});
Loading