fix: refine composer reply focus

This commit is contained in:
Shakker 2026-05-08 14:21:35 +01:00
parent d3c8d6027e
commit 2157fa769a
No known key found for this signature in database
7 changed files with 286 additions and 210 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClickClack</title>
<script type="module" crossorigin src="/assets/index-Ci-0jUjM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnVRnSi7.css">
<script type="module" crossorigin src="/assets/index-vInMpaH9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BddjDjB3.css">
</head>
<body>
<div id="app"></div>

View File

@ -788,7 +788,15 @@
<svelte:window
onkeydown={(event) => {
if (event.key === "Escape") closeModal();
if (event.key === "Escape") {
if (isModalOpen()) {
closeModal();
} else if (replyTarget) {
event.preventDefault();
clearReplyTarget();
return;
}
}
redirectTypingToComposer(event);
}}
/>
@ -1314,34 +1322,6 @@
void sendMessage();
}}
>
<div class="composer-toolbar" aria-label="Message tools">
<button type="button" title="Bold" aria-label="Bold" onclick={() => applyMarkdownWrap("**")}>
<strong>B</strong>
</button>
<button type="button" title="Italic" aria-label="Italic" onclick={() => applyMarkdownWrap("_")}>
<em>I</em>
</button>
<button type="button" title="Code" aria-label="Code" onclick={() => applyMarkdownWrap("`")}>
<span>{`<>`}</span>
</button>
<button type="button" title="Code block" aria-label="Code block" onclick={() => applyMarkdownWrap("```", "\n```")}>
<span>{`{}`}</span>
</button>
<button type="button" title="Link" aria-label="Link" onclick={() => appendToComposer("[label](https://)")}>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M10 13a5 5 0 0 0 7.07 0l2.12-2.12a5 5 0 0 0-7.07-7.07L11 4.93M14 11a5 5 0 0 0-7.07 0L4.81 13.12a5 5 0 0 0 7.07 7.07L13 19.07"/>
</svg>
</button>
<button
type="button"
title="GIF picker"
aria-label="GIF picker"
class:active={showGifPicker}
onclick={() => (showGifPicker = !showGifPicker)}
>
GIF
</button>
</div>
{#if showGifPicker}
<section class="gif-picker" aria-label="GIF picker panel">
<div class="gif-picker-head">
@ -1358,50 +1338,80 @@
</div>
</section>
{/if}
{#if pendingUpload}
<div class="composer-attachment">
<span class="attachment-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M21.44 11.05 12.5 20a6 6 0 0 1-8.49-8.49l8.49-8.48a4 4 0 0 1 5.66 5.66l-8.49 8.49a2 2 0 0 1-2.83-2.83L13.41 7.5"/></svg>
</span>
{#if isImageUpload(pendingUpload)}
<img class="pending-image" src={uploadURL(pendingUpload)} alt={pendingUpload.filename} />
{/if}
<span class="attachment-name">{pendingUpload.filename} · {formatBytes(pendingUpload.byte_size)}</span>
<button type="button" class="attachment-remove" aria-label="Remove attachment" onclick={() => (pendingUpload = null)}>×</button>
<div class="composer-card">
{#if pendingUpload}
<div class="composer-attachment">
<span class="attachment-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M21.44 11.05 12.5 20a6 6 0 0 1-8.49-8.49l8.49-8.48a4 4 0 0 1 5.66 5.66l-8.49 8.49a2 2 0 0 1-2.83-2.83L13.41 7.5"/></svg>
</span>
{#if isImageUpload(pendingUpload)}
<img class="pending-image" src={uploadURL(pendingUpload)} alt={pendingUpload.filename} />
{/if}
<span class="attachment-name">{pendingUpload.filename} · {formatBytes(pendingUpload.byte_size)}</span>
<button type="button" class="attachment-remove" aria-label="Remove attachment" onclick={() => (pendingUpload = null)}>×</button>
</div>
{/if}
{#if replyTarget && replyContext === (selectedDirectID ? "dm" : "channel")}
<div class="quote-preview" aria-label="Replying to message">
<span class="quote-bar" aria-hidden="true"></span>
<span class="quote-preview-body">
<span class="quote-preview-label">Replying to <strong>{replyTarget.author?.display_name || "Local User"}</strong></span>
<span class="quote-preview-snippet">{quoteSnippet(replyTarget.body)}</span>
</span>
<button type="button" class="quote-preview-clear" aria-label="Cancel reply" onclick={clearReplyTarget}>×</button>
</div>
{/if}
<div class="composer-row">
<label class="composer-icon" title="Upload file">
<input type="file" aria-label="Upload file" onchange={uploadFile} />
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M21.44 11.05 12.5 20a6 6 0 0 1-8.49-8.49l8.49-8.48a4 4 0 0 1 5.66 5.66l-8.49 8.49a2 2 0 0 1-2.83-2.83L13.41 7.5"/>
</svg>
</label>
<textarea
bind:this={messageInput}
bind:value={messageBody}
use:autoGrow={messageBody}
rows="1"
placeholder={selectedDirect ? `Message ${dmTitle(selectedDirect)}` : selectedChannel ? `Message #${selectedChannel.name}` : "Pick a channel to start"}
aria-label="Message body"
onfocus={() => (activeComposerContext = "message")}
onkeydown={handleComposerKey}
></textarea>
<button type="submit" class="send" aria-label="Send" disabled={!messageBody.trim()}>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M3 3.5 21 12 3 20.5l3.6-7.5L15 12 6.6 11l-3.6-7.5Z"/>
</svg>
</button>
</div>
{/if}
{#if replyTarget && replyContext === (selectedDirectID ? "dm" : "channel")}
<div class="quote-preview" aria-label="Replying to message">
<span class="quote-bar" aria-hidden="true"></span>
<span class="quote-preview-body">
<span class="quote-preview-label">Replying to <strong>{replyTarget.author?.display_name || "Local User"}</strong></span>
<span class="quote-preview-snippet">{quoteSnippet(replyTarget.body)}</span>
</span>
<button type="button" class="quote-preview-clear" aria-label="Cancel reply" onclick={clearReplyTarget}>×</button>
<div class="composer-toolbar" aria-label="Message tools">
<button type="button" title="Bold" aria-label="Bold" onclick={() => applyMarkdownWrap("**")}>
<strong>B</strong>
</button>
<button type="button" title="Italic" aria-label="Italic" onclick={() => applyMarkdownWrap("_")}>
<em>I</em>
</button>
<button type="button" title="Code" aria-label="Code" onclick={() => applyMarkdownWrap("`")}>
<span>{`<>`}</span>
</button>
<button type="button" title="Code block" aria-label="Code block" onclick={() => applyMarkdownWrap("```", "\n```")}>
<span>{`{}`}</span>
</button>
<button type="button" title="Link" aria-label="Link" onclick={() => appendToComposer("[label](https://)")}>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M10 13a5 5 0 0 0 7.07 0l2.12-2.12a5 5 0 0 0-7.07-7.07L11 4.93M14 11a5 5 0 0 0-7.07 0L4.81 13.12a5 5 0 0 0 7.07 7.07L13 19.07"/>
</svg>
</button>
<button
type="button"
title="GIF picker"
aria-label="GIF picker"
class:active={showGifPicker}
onclick={() => (showGifPicker = !showGifPicker)}
>
GIF
</button>
</div>
{/if}
<div class="composer-row">
<label class="composer-icon" title="Upload file">
<input type="file" aria-label="Upload file" onchange={uploadFile} />
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M21.44 11.05 12.5 20a6 6 0 0 1-8.49-8.49l8.49-8.48a4 4 0 0 1 5.66 5.66l-8.49 8.49a2 2 0 0 1-2.83-2.83L13.41 7.5"/>
</svg>
</label>
<textarea
bind:this={messageInput}
bind:value={messageBody}
use:autoGrow={messageBody}
rows="1"
placeholder={selectedDirect ? `Message ${dmTitle(selectedDirect)}` : selectedChannel ? `Message #${selectedChannel.name}` : "Pick a channel to start"}
aria-label="Message body"
onfocus={() => (activeComposerContext = "message")}
onkeydown={handleComposerKey}
></textarea>
<button type="submit" class="send" aria-label="Send" disabled={!messageBody.trim()}>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M3 3.5 21 12 3 20.5l3.6-7.5L15 12 6.6 11l-3.6-7.5Z"/>
</svg>
</button>
</div>
<div class="composer-hint">
<span><kbd>Enter</kbd> to send · <kbd>Shift</kbd>+<kbd>Enter</kbd> for newline · Markdown supported</span>
@ -1576,32 +1586,34 @@
void sendReply();
}}
>
{#if replyTarget && replyContext === "thread"}
<div class="quote-preview" aria-label="Replying to message">
<span class="quote-bar" aria-hidden="true"></span>
<span class="quote-preview-body">
<span class="quote-preview-label">Replying to <strong>{replyTarget.author?.display_name || "Local User"}</strong></span>
<span class="quote-preview-snippet">{quoteSnippet(replyTarget.body)}</span>
</span>
<button type="button" class="quote-preview-clear" aria-label="Cancel reply" onclick={clearReplyTarget}>×</button>
<div class="composer-card">
{#if replyTarget && replyContext === "thread"}
<div class="quote-preview" aria-label="Replying to message">
<span class="quote-bar" aria-hidden="true"></span>
<span class="quote-preview-body">
<span class="quote-preview-label">Replying to <strong>{replyTarget.author?.display_name || "Local User"}</strong></span>
<span class="quote-preview-snippet">{quoteSnippet(replyTarget.body)}</span>
</span>
<button type="button" class="quote-preview-clear" aria-label="Cancel reply" onclick={clearReplyTarget}>×</button>
</div>
{/if}
<div class="composer-row">
<textarea
bind:this={replyInput}
bind:value={replyBody}
use:autoGrow={replyBody}
rows="1"
placeholder="Reply in thread"
aria-label="Reply body"
onfocus={() => (activeComposerContext = "thread")}
onkeydown={handleReplyKey}
></textarea>
<button type="submit" class="send" aria-label="Reply" disabled={!replyBody.trim()}>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M3 3.5 21 12 3 20.5l3.6-7.5L15 12 6.6 11l-3.6-7.5Z"/>
</svg>
</button>
</div>
{/if}
<div class="composer-row">
<textarea
bind:this={replyInput}
bind:value={replyBody}
use:autoGrow={replyBody}
rows="1"
placeholder="Reply in thread"
aria-label="Reply body"
onfocus={() => (activeComposerContext = "thread")}
onkeydown={handleReplyKey}
></textarea>
<button type="submit" class="send" aria-label="Reply" disabled={!replyBody.trim()}>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M3 3.5 21 12 3 20.5l3.6-7.5L15 12 6.6 11l-3.6-7.5Z"/>
</svg>
</button>
</div>
</form>
{:else if selectedProfile}

View File

@ -1493,40 +1493,66 @@ button.ghost {
position: relative;
}
.composer-card {
display: flex;
flex-direction: column;
background: var(--panel);
border: 1px solid var(--line-strong);
border-radius: var(--radius-lg);
overflow: hidden;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.composer:focus-within .composer-card {
border-color: color-mix(in srgb, var(--accent) 55%, var(--line-strong));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
.composer-toolbar {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 2px;
width: 100%;
padding: 6px 8px;
border: 1px solid var(--line);
border-bottom: 0;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: color-mix(in srgb, var(--panel-2) 72%, var(--panel));
padding: 4px 6px;
margin: 0;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
background: color-mix(in srgb, var(--panel-2) 35%, transparent);
box-shadow: none;
}
.composer-toolbar button {
min-width: 30px;
height: 28px;
min-width: 28px;
height: 26px;
display: grid;
place-items: center;
border: 0;
border-radius: 7px;
border-radius: 6px;
background: transparent;
color: var(--muted);
font-size: 11px;
font-weight: 800;
color: var(--text);
opacity: 0.78;
font-size: 11.5px;
font-weight: 700;
transition:
background 100ms ease,
color 100ms ease,
opacity 100ms ease,
transform 80ms ease;
}
.composer-toolbar button:hover,
.composer-toolbar button:hover {
background: var(--hover-strong);
color: var(--text-strong);
opacity: 1;
}
.composer-toolbar button.active {
background: var(--accent-soft);
color: var(--accent);
opacity: 1;
}
.composer-toolbar button:active {
@ -1636,22 +1662,19 @@ button.ghost {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: end;
gap: 6px;
padding: 10px 8px 8px 10px;
background: var(--panel);
border: 1px solid var(--line-strong);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
transition:
border-color 120ms ease,
box-shadow 120ms ease;
padding: 6px 8px 6px 10px;
background: transparent;
border: 0;
border-radius: 0;
}
.composer:focus-within .composer-toolbar,
.composer-row:focus-within {
border-color: var(--accent);
border-color: transparent;
}
.composer-row:focus-within {
box-shadow: var(--accent-glow);
box-shadow: none;
}
.composer-icon {
@ -1930,7 +1953,7 @@ button.ghost {
}
.reply-composer .composer-row {
border-radius: var(--radius-lg);
border-radius: 0;
}
.thread-empty {
@ -2591,14 +2614,16 @@ button.ghost {
.quote-preview {
display: flex;
align-items: stretch;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
margin-bottom: 0.4rem;
background: rgba(99, 102, 241, 0.08);
border-left: 3px solid var(--accent, #6366f1);
border-radius: 6px;
font-size: 0.85rem;
padding: 6px 10px 6px 12px;
margin: 0;
background: color-mix(in srgb, var(--accent) 5%, transparent);
border: 0;
border-bottom: 1px solid var(--line);
border-radius: 0;
font-size: 12.5px;
color: var(--muted);
}
.quote-preview .quote-bar {
@ -2607,36 +2632,51 @@ button.ghost {
.quote-preview-body {
display: flex;
flex-direction: column;
gap: 0.15rem;
align-items: baseline;
gap: 0.4rem;
flex: 1;
min-width: 0;
overflow: hidden;
}
.quote-preview-label {
font-size: 0.75rem;
color: var(--text-muted, #6b7280);
font-size: 12px;
color: color-mix(in srgb, var(--accent) 85%, var(--text));
white-space: nowrap;
}
.quote-preview-label strong {
color: var(--text-strong);
font-weight: 600;
}
.quote-preview-snippet {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text, inherit);
color: var(--muted);
font-size: 12px;
flex: 1;
min-width: 0;
}
.quote-preview-clear {
background: transparent;
border: 0;
color: var(--text-muted, #6b7280);
font-size: 1.1rem;
color: var(--muted);
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 0 0.25rem;
padding: 2px 6px;
border-radius: 6px;
transition:
background 100ms ease,
color 100ms ease;
}
.quote-preview-clear:hover {
color: var(--text, inherit);
color: var(--text-strong);
background: var(--hover-strong);
}
.reply-quote-btn {

View File

@ -156,4 +156,28 @@ test.describe("type-to-focus composer", () => {
const afterClearHeight = await composer.evaluate((el) => el.getBoundingClientRect().height);
expect(Math.abs(afterClearHeight - initialHeight)).toBeLessThan(2);
});
test("global Escape clears the reply target even when composer is not focused", async ({
page,
}) => {
const composer = page.getByLabel("Message body");
await composer.fill("the original draft");
await page.getByRole("button", { name: "Send" }).click();
const originalRow = page.locator(".message-row", {
has: page.locator(".markdown").filter({ hasText: "the original draft" }),
});
await originalRow.hover();
await originalRow.getByRole("button", { name: "Reply" }).click();
const chip = page.getByLabel("Replying to message");
await expect(chip).toBeVisible();
// Move focus away from the composer.
await page.locator("body").click({ position: { x: 5, y: 5 } });
await expect(composer).not.toBeFocused();
await page.keyboard.press("Escape");
await expect(chip).toHaveCount(0);
});
});