Skip to content

Commit ad48aa0

Browse files
JohnMcLearclaude
andauthored
feat(ux): show the add-comment button as disabled until text is selected (#96) (#435)
* feat(ux): show the add-comment button as disabled until text is selected (#96) Commenting requires a selection; clicking the toolbar button with nothing selected only popped a hint. Now the button is greyed (opacity + not-allowed cursor, aria-disabled) whenever the selection is empty, and becomes active as soon as text is selected — driven off aceEditEvent's rep. Deliberately visual-only: the button stays clickable and still falls back to the existing 'select text first' hint, so the primary action can never get stuck disabled if a selection event is missed. Pure plugin change — works on the latest release (2.7.3) and develop without a core update. Adds a Playwright test that checks the state toggles disabled→enabled→disabled as the selection changes. Closes #96 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#96): block add-comment activation while disabled + cache hot path - Don't open the new-comment form when there is no selection: an aria-disabled control must not be activatable by mouse or keyboard. - Cache the button collection and skip DOM work in updateAddCommentButtonState when the selection state is unchanged (aceEditEvent is a hot hook). - Test: assert the disabled button does not open the form, and that it does once text is selected. * fix(#96): guard add-comment on the live selection, not a stale flag The cached _addCommentDisabled flag lags a just-made selection (it's updated by the async aceEditEvent), so guarding the click on it blocked legitimate clicks made immediately after selecting — breaking the shared addCommentToLine test helper and every comment-box test that uses it. Check the live ace selection at click time instead, defaulting to allow on any read failure (displayNewCommentForm still guards the empty case). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4f3c29b commit ad48aa0

3 files changed

Lines changed: 140 additions & 3 deletions

File tree

static/css/comment.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,14 @@ input.error, textarea.error {
396396
margin-right: 6px;
397397
}
398398

399+
/* #96: visually mark the toolbar "Add comment" button as unavailable until
400+
text is selected (commenting needs a selection). It is also not activatable
401+
while disabled (see the click guard in static/js/index.js). */
402+
.addComment.comment-btn-disabled {
403+
opacity: .4;
404+
cursor: not-allowed;
405+
}
406+
399407
/* OTHER */
400408
.hidden {
401409
display: none;

static/js/index.js

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,21 @@ EpComments.prototype.init = async function () {
162162
// On click comment icon toolbar
163163
$('.addComment').on('click', (e) => {
164164
e.preventDefault(); // stops focus from being lost
165+
// An aria-disabled control must not be activatable by mouse or keyboard
166+
// (keyboard Enter/Space on the link also fires click), so don't open the
167+
// form while there is no selection (#96). Check the LIVE selection rather
168+
// than the cached disabled-state, which can lag a just-made selection and
169+
// would wrongly block a valid click.
170+
if (!this.hasSelectionForComment()) return;
165171
this.displayNewCommentForm();
166172
});
173+
// Start disabled-looking: nothing is selected on load (#96). aceEditEvent
174+
// keeps it in sync from here on.
175+
this.$addCommentButtons = $('.addComment');
176+
this._addCommentDisabled = true;
177+
this._lastHasSelection = false;
178+
this.$addCommentButtons.addClass('comment-btn-disabled').attr('aria-disabled', 'true');
179+
167180
// #8: don't offer commenting to read-only viewers unless an admin enabled it.
168181
if (clientVars.readonly && !clientVars.allowReadonlyComments) {
169182
$('.addComment').hide();
@@ -1043,6 +1056,27 @@ const getXYOffsetOfRep = (rep) => {
10431056
}
10441057
};
10451058

1059+
// #96: keep the toolbar "Add comment" button's state in sync with the
1060+
// selection — commenting requires a non-empty selection. While disabled the
1061+
// button is also not activatable (see the click guard above).
1062+
EpComments.prototype.updateAddCommentButtonState = function (rep) {
1063+
if (!rep || !rep.selStart || !rep.selEnd) return;
1064+
const hasSelection =
1065+
rep.selStart[0] !== rep.selEnd[0] || rep.selStart[1] !== rep.selEnd[1];
1066+
// aceEditEvent is a hot hook; skip the DOM query/writes when the selection
1067+
// state hasn't changed since the last event (#96).
1068+
if (hasSelection === this._lastHasSelection) return;
1069+
this._lastHasSelection = hasSelection;
1070+
this._addCommentDisabled = !hasSelection;
1071+
// Reuse the cached button collection; re-query once if it wasn't ready yet.
1072+
if (!this.$addCommentButtons || !this.$addCommentButtons.length) {
1073+
this.$addCommentButtons = $('.addComment');
1074+
}
1075+
this.$addCommentButtons
1076+
.toggleClass('comment-btn-disabled', !hasSelection)
1077+
.attr('aria-disabled', String(!hasSelection));
1078+
}
1079+
10461080
// #95: floating "add comment" button anchored to the current selection.
10471081
// Lazily build the element once, in the same container the new-comment popup
10481082
// uses (#editorcontainerbox), so it shares the popup's coordinate space.
@@ -1236,6 +1270,26 @@ EpComments.prototype.navigateComment = function ($el, dir) {
12361270
}
12371271
};
12381272

1273+
// True when there is a non-collapsed selection right now. Read live from ace so
1274+
// the add-comment activation guard (#96) reflects the actual selection rather
1275+
// than the cached, possibly-stale, disabled-state flag.
1276+
EpComments.prototype.hasSelectionForComment = function () {
1277+
// Default to allowing: displayNewCommentForm reads the selection itself and
1278+
// bails (with a hint) when empty, so only block here when we affirmatively
1279+
// observe a collapsed selection — never on a read failure (#96).
1280+
let hasSelection = true;
1281+
try {
1282+
this.ace.callWithAce((ace) => {
1283+
const rep = ace.ace_getRep();
1284+
if (rep && rep.selStart && rep.selEnd) {
1285+
hasSelection =
1286+
rep.selStart[0] !== rep.selEnd[0] || rep.selStart[1] !== rep.selEnd[1];
1287+
}
1288+
}, 'hasSelectionForComment', true);
1289+
} catch (err) { hasSelection = true; }
1290+
return hasSelection;
1291+
};
1292+
12391293
EpComments.prototype.displayNewCommentForm = function () {
12401294
this.hideFloatingAddCommentButton();
12411295
const rep = {};
@@ -1648,10 +1702,11 @@ const hooks = {
16481702

16491703
if (['setup', 'setBaseText', 'importText'].includes(eventType)) return;
16501704

1651-
// Show/position the floating "add comment" button next to the current
1652-
// selection (#95). aceEditEvent fires on selection changes, so this keeps
1653-
// the button anchored to the text and hidden when nothing is selected.
1705+
// aceEditEvent fires on cursor/selection changes — keep both selection-driven
1706+
// affordances current: the toolbar comment button's enabled-look (#96) and
1707+
// the floating add-comment button anchored to the selection (#95).
16541708
if (pad.plugins.ep_comments_page) {
1709+
pad.plugins.ep_comments_page.updateAddCommentButtonState(context.rep);
16551710
pad.plugins.ep_comments_page.updateFloatingAddCommentButton(context.rep);
16561711
}
16571712

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {expect, test} from '@playwright/test';
2+
import {getPadBody} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
3+
import {aNewCommentsPad} from '../helper/comments';
4+
5+
// #96: the toolbar "Add comment" button should look unavailable when there is
6+
// no selection (commenting needs selected text) and become available once text
7+
// is selected. While disabled it must also not be activatable (an aria-disabled
8+
// control must not open the form on click/keyboard). This spec checks the visual
9+
// state toggles with the selection AND that activation is blocked while disabled.
10+
test.describe('ep_comments_page - Add-comment button state (#96)', () => {
11+
test('button is disabled-looking with no selection, enabled with one',
12+
async ({page}) => {
13+
test.setTimeout(60_000);
14+
await aNewCommentsPad(page);
15+
const inner = await getPadBody(page);
16+
const addBtn = page.locator('.addComment');
17+
18+
await inner.click();
19+
await page.keyboard.press('Control+A');
20+
await page.keyboard.press('Delete');
21+
await page.keyboard.type('hello world');
22+
23+
// After typing, the caret is collapsed → button looks disabled.
24+
await expect.poll(async () =>
25+
addBtn.first().evaluate((el) =>
26+
el.classList.contains('comment-btn-disabled'))).toBe(true);
27+
28+
// Select all → button becomes available.
29+
await page.keyboard.press('Control+A');
30+
await expect.poll(async () =>
31+
addBtn.first().evaluate((el) =>
32+
el.classList.contains('comment-btn-disabled'))).toBe(false);
33+
expect(await addBtn.first().getAttribute('aria-disabled')).toBe('false');
34+
35+
// Collapse the selection again → back to disabled-looking.
36+
await page.keyboard.press('ArrowRight');
37+
await expect.poll(async () =>
38+
addBtn.first().evaluate((el) =>
39+
el.classList.contains('comment-btn-disabled'))).toBe(true);
40+
});
41+
42+
test('clicking the disabled button does not open the new-comment form',
43+
async ({page}) => {
44+
test.setTimeout(60_000);
45+
await aNewCommentsPad(page);
46+
const inner = await getPadBody(page);
47+
const addBtn = page.locator('.addComment');
48+
const newCommentForm = page.locator('.new-comment-popup, #newComment');
49+
50+
await inner.click();
51+
await page.keyboard.press('Control+A');
52+
await page.keyboard.press('Delete');
53+
await page.keyboard.type('hello world');
54+
await page.keyboard.press('ArrowRight'); // collapse selection
55+
56+
// Disabled (no selection).
57+
await expect.poll(async () =>
58+
addBtn.first().evaluate((el) =>
59+
el.classList.contains('comment-btn-disabled'))).toBe(true);
60+
61+
// Activating it must NOT reveal the new-comment form.
62+
await addBtn.first().dispatchEvent('click');
63+
await page.waitForTimeout(500);
64+
await expect(newCommentForm).toBeHidden();
65+
66+
// With a selection the same activation DOES open the form.
67+
await page.keyboard.press('Control+A');
68+
await expect.poll(async () =>
69+
addBtn.first().evaluate((el) =>
70+
el.classList.contains('comment-btn-disabled'))).toBe(false);
71+
await addBtn.first().dispatchEvent('click');
72+
await expect(newCommentForm.first()).toBeVisible({timeout: 5_000});
73+
});
74+
});

0 commit comments

Comments
 (0)