From 185e7dfe7296eb4e1e2c024dca04393c7ca8f0f7 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Mon, 2 Nov 2026 18:37:14 +0800 Subject: [PATCH 1/2] Improve comment widget accessibility and pagination state --- packages/comment-widget/src/base-form.ts | 10 +- .../comment-widget/src/comment-content.ts | 3 + packages/comment-widget/src/comment-detail.ts | 2 +- .../comment-widget/src/comment-edit-form.ts | 22 +- packages/comment-widget/src/comment-item.ts | 27 ++- packages/comment-widget/src/comment-list.ts | 65 +++++- .../comment-widget/src/comment-management.ts | 4 +- .../comment-widget/src/comment-replies.ts | 2 +- .../src/generated/locales/es.ts | 21 +- .../src/generated/locales/zh-CN.ts | 21 +- .../src/generated/locales/zh-TW.ts | 21 +- packages/comment-widget/src/loading-block.ts | 8 +- packages/comment-widget/src/reply-item.ts | 15 +- .../comment-widget/tests/browser-setup.js | 5 + .../tests/comment-item.browser.test.js | 46 ++++ .../tests/comment-list.browser.test.js | 199 ++++++++++++++++++ .../tests/comment-management.browser.test.js | 4 +- .../tests/comment-permalink.browser.test.js | 2 +- .../tests/quote-badge.browser.test.js | 47 +++++ .../tests/upvote.browser.test.js | 7 +- packages/comment-widget/xliff/es.xlf | 84 +++++--- packages/comment-widget/xliff/zh-CN.xlf | 84 +++++--- packages/comment-widget/xliff/zh-TW.xlf | 84 +++++--- 23 files changed, 633 insertions(+), 150 deletions(-) create mode 100644 packages/comment-widget/tests/comment-item.browser.test.js create mode 100644 packages/comment-widget/tests/quote-badge.browser.test.js diff --git a/packages/comment-widget/src/base-form.ts b/packages/comment-widget/src/base-form.ts index dcdaaad5..4248eca3 100644 --- a/packages/comment-widget/src/base-form.ts +++ b/packages/comment-widget/src/base-form.ts @@ -396,7 +396,7 @@ export class BaseForm extends LitElement { @@ -474,7 +474,7 @@ export class BaseForm extends LitElement { ${msg('Login')} @@ -500,7 +500,7 @@ export class BaseForm extends LitElement { this.allowNotificationConfigurable, () => html`
- +
` )} @@ -523,7 +523,7 @@ export class BaseForm extends LitElement { ${when( this.showLoading, diff --git a/packages/comment-widget/src/comment-content.ts b/packages/comment-widget/src/comment-content.ts index 774a34ab..b22bec8d 100644 --- a/packages/comment-widget/src/comment-content.ts +++ b/packages/comment-widget/src/comment-content.ts @@ -137,6 +137,9 @@ export class CommentContent extends LitElement { if (this.zoomedImage) void imageZoom?.close(); this.applyLinkAttributes(); this.renderRoot.querySelectorAll('.content img').forEach((image) => { + if (!image.hasAttribute('loading')) { + image.setAttribute('loading', 'lazy'); + } const anchor = image.closest('a'); if (anchor) { // Split the link around the image, preserving linked text on both sides. diff --git a/packages/comment-widget/src/comment-detail.ts b/packages/comment-widget/src/comment-detail.ts index eb1bdcf8..9eca932d 100644 --- a/packages/comment-widget/src/comment-detail.ts +++ b/packages/comment-widget/src/comment-detail.ts @@ -127,7 +127,7 @@ export class CommentDetail extends LitElement { this.activeReplyItem?.closeReplyForm(); this.activeReplyItem = event.detail; }}> - + ${this.loading ? html`` : this.error ? html`

${this.error}

${this.retryable ? html`` : ''}` : keyed(this.requestId, html``)} `; } diff --git a/packages/comment-widget/src/comment-edit-form.ts b/packages/comment-widget/src/comment-edit-form.ts index d81ad1c3..07d27337 100644 --- a/packages/comment-widget/src/comment-edit-form.ts +++ b/packages/comment-widget/src/comment-edit-form.ts @@ -76,9 +76,23 @@ export class CommentEditForm extends LitElement { override disconnectedCallback(): void { this.removeEventListener('keydown', this.onKeydown); + window.removeEventListener('beforeunload', this.onBeforeUnload); super.disconnectedCallback(); } + private onBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ''; + }; + + private syncBeforeUnload() { + if (this.dirty) { + window.addEventListener('beforeunload', this.onBeforeUnload); + } else { + window.removeEventListener('beforeunload', this.onBeforeUnload); + } + } + private onKeydown = (event: KeyboardEvent) => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); @@ -137,6 +151,7 @@ export class CommentEditForm extends LitElement { if (this.content === this.initialRaw) { this.content = this.baseline; } + this.syncBeforeUnload(); editor.setFocus(); } } @@ -146,6 +161,7 @@ export class CommentEditForm extends LitElement { ) { this.content = event.detail.content; this.errorMessage = ''; + this.syncBeforeUnload(); } private get hasContent() { @@ -192,6 +208,7 @@ export class CommentEditForm extends LitElement { } ); this.toastManager?.success(msg('Comment updated successfully')); + window.removeEventListener('beforeunload', this.onBeforeUnload); this.dispatchEvent( new CustomEvent('comment-managed', { bubbles: true, @@ -230,6 +247,7 @@ export class CommentEditForm extends LitElement { if (this.dirty && !window.confirm(msg('Discard your changes?'))) { return; } + window.removeEventListener('beforeunload', this.onBeforeUnload); this.dispatchEvent( new CustomEvent('edit-close', { bubbles: true, composed: true }) ); @@ -257,7 +275,7 @@ export class CommentEditForm extends LitElement { @@ -282,7 +300,7 @@ export class CommentEditForm extends LitElement { type="button" ?disabled=${!this.canSave} @click=${this.handleSave} - class="edit-form-submit outline-none focus-visible:shadow-input h-9 text-sm inline-flex items-center justify-center gap-2 bg-primary-1 text-primary-contrast px-4 rounded-base hover:opacity-80 transition-[opacity,box-shadow] disabled:opacity-50 disabled:cursor-not-allowed" + class="edit-form-submit outline-none focus-visible:shadow-input h-9 text-sm inline-flex items-center justify-center gap-2 bg-primary-1 text-primary-contrast px-4 rounded-base hover:brightness-95 transition-[filter,box-shadow] disabled:opacity-50 disabled:cursor-not-allowed" > ${when(this.saving, () => html``)} ${msg('Save')} diff --git a/packages/comment-widget/src/comment-item.ts b/packages/comment-widget/src/comment-item.ts index 9575c355..424d3378 100644 --- a/packages/comment-widget/src/comment-item.ts +++ b/packages/comment-widget/src/comment-item.ts @@ -122,6 +122,23 @@ export class CommentItem extends LitElement { } } + private get repliesToggleUsesReplyForm() { + return !this.detail && !this.configMapData?.basic.withReplies; + } + + private get repliesToggleExpanded() { + return this.repliesToggleUsesReplyForm + ? this.showReplyForm + : this.showReplies; + } + + private get repliesToggleLabel() { + if (this.repliesToggleUsesReplyForm) { + return this.showReplyForm ? msg('Cancel reply') : msg('Add Reply'); + } + return this.showReplies ? msg('Hide Replies') : msg('Show Replies'); + } + handleShowReplies() { if (!this.detail && !this.configMapData?.basic.withReplies) { this.handleToggleReplyForm(); @@ -192,7 +209,7 @@ export class CommentItem extends LitElement { .private=${this.comment?.spec.hidden} .editing=${this.showEditForm} > - @@ -210,10 +228,11 @@ export class CommentItem extends LitElement { this.comment?.status?.visibleReplyCount === 0 ? '' : html` - ` } @@ -221,11 +240,11 @@ export class CommentItem extends LitElement { ${when( this.detail || this.configMapData?.basic.withReplies, () => html` - ` )} diff --git a/packages/comment-widget/src/comment-list.ts b/packages/comment-widget/src/comment-list.ts index dd070049..7dbdfe8e 100644 --- a/packages/comment-widget/src/comment-list.ts +++ b/packages/comment-widget/src/comment-list.ts @@ -73,8 +73,13 @@ export class CommentList extends LitElement { @state() loading = false; + @state() + loadFailed = false; + private requestId = 0; + private pageChangeShouldPush = false; + private activeReplyItem?: { closeReplyForm(): void }; private onReplyFormOpen(event: CustomEvent<{ closeReplyForm(): void }>) { @@ -96,7 +101,11 @@ export class CommentList extends LitElement { } override disconnectedCallback(): void { + // Invalidate in-flight requests so a late response cannot rewrite the URL. + this.requestId++; + this.pageChangeShouldPush = false; window.removeEventListener('halo:comment:created', this.onCommentCreated); + window.removeEventListener('popstate', this.onLocationChange); this.activeReplyItem?.closeReplyForm(); this.activeReplyItem = undefined; super.disconnectedCallback(); @@ -113,21 +122,59 @@ export class CommentList extends LitElement { async onPageChange(e: CustomEvent) { const data = e.detail; this.comments.page = data.page; + this.pageChangeShouldPush = true; await this.fetchComments({ scrollIntoView: true }); } override connectedCallback(): void { super.connectedCallback(); - this.fetchComments(); + this.fetchComments({ page: this.readPageFromUrl() }); // Handle halo:comment:created event, then reload the comment list window.addEventListener('halo:comment:created', this.onCommentCreated); + window.addEventListener('popstate', this.onLocationChange); } private onCommentCreated = () => { this.fetchComments({ page: 1, scrollIntoView: true }); }; + private onLocationChange = () => { + this.pageChangeShouldPush = false; + const page = this.readPageFromUrl() ?? 1; + if (page !== this.comments.page) { + void this.fetchComments({ page, scrollIntoView: true }); + } + }; + + private readPageFromUrl() { + const page = Number( + new URL(location.href).searchParams.get('comment-page') + ); + return Number.isInteger(page) && page > 0 ? page : undefined; + } + + private syncPageToUrl() { + const url = new URL(location.href); + const current = url.searchParams.get('comment-page'); + const next = this.comments.page > 1 ? String(this.comments.page) : null; + if (current === next) { + this.pageChangeShouldPush = false; + return; + } + if (next === null) { + url.searchParams.delete('comment-page'); + } else { + url.searchParams.set('comment-page', next); + } + if (this.pageChangeShouldPush) { + history.pushState(history.state, '', url); + } else { + history.replaceState(history.state, '', url); + } + this.pageChangeShouldPush = false; + } + async fetchComments(options?: { page?: number; scrollIntoView?: boolean; @@ -184,9 +231,13 @@ export class CommentList extends LitElement { } } if (requestId !== this.requestId) return; + this.loadFailed = false; this.comments = data; + this.syncPageToUrl(); } catch (_error) { if (requestId !== this.requestId) return; + this.pageChangeShouldPush = false; + this.loadFailed = true; console.error(_error); this.toastManager?.error( msg('Failed to load comment list, please try again later') @@ -214,9 +265,15 @@ export class CommentList extends LitElement { ? html` ` : html`
-
- ${msg(html`${this.comments.total} Comments`)} -
+

+ ${msg(html`${this.comments.total} Comments`)} +

+ + ${when( + this.comments.total === 0 && !this.loadFailed, + () => + html`

${msg('No comments yet')}

` + )}
${repeat( diff --git a/packages/comment-widget/src/comment-management.ts b/packages/comment-widget/src/comment-management.ts index 5cfc4035..77950639 100644 --- a/packages/comment-widget/src/comment-management.ts +++ b/packages/comment-widget/src/comment-management.ts @@ -154,10 +154,10 @@ export class CommentManagement extends LitElement { : '' }
` )} - ${when(this.targetOnly, () => html``)} + ${when(this.targetOnly, () => html``)} ${when(this.loading, () => html` `)} ${when( this.hasNext, diff --git a/packages/comment-widget/src/generated/locales/es.ts b/packages/comment-widget/src/generated/locales/es.ts index b2482c12..81b3a735 100644 --- a/packages/comment-widget/src/generated/locales/es.ts +++ b/packages/comment-widget/src/generated/locales/es.ts @@ -21,7 +21,6 @@ 's107ccef507b51f2c': `Completa la verificación`, 's15e33945d13c5176': `Se está confirmando tu envío anterior. Inténtalo más tarde; se conservarán tus imágenes.`, 's176fe95698857ad9': `El comentario o la respuesta ha cambiado. Copia tu borrador y vuelve a abrir el editor para cargar la última versión.`, -'s17c8f8524e36987d': `Permitir notificación`, 's19172ed3838854fc': `Copiar enlace`, 's1c6fefb092506753': `Error al cargar la lista de comentarios, por favor intente más tarde`, 's1d468f888124a55e': `Tu borrador ha cambiado. Vuelve a abrirlo antes de reintentar.`, @@ -42,10 +41,11 @@ 's34239948d1de01f1': `Verificación no disponible. Haz clic para reintentar.`, 's35c3125c7750681e': `Cita`, 's3643189d1abbb7f4': `Código`, +'s3e9219b89d8a899b': `Ver todas las respuestas`, 's3fb33d17bad61aa9': `Comentario enviado con éxito, pendiente de revisión`, -'s424590dc43eddb44': `Hacer privado`, 's44851a8adf059eef': `Fijado`, 's4c0e15f9073382e6': `Error al obtener el código de verificación`, +'s4d5f8e719d513987': `En respuesta a`, 's4d76c6944ffb0540': `No se pudo guardar. Es posible que el comentario se haya eliminado o que la versión actual de Halo no admita la edición. Tu borrador se ha conservado.`, 's5184a3f3e2f7b603': `Actualmente anónimo. Después de seleccionar la opción privada, el comentario solo será visible para el administrador del sitio.`, 's523eb9043213ff0d': `Itálica`, @@ -53,13 +53,13 @@ 's58a3c1ecd4dd06cf': `Tachado`, 's59b95f09e3b42060': `La verificación ha fallado. Vuelve a verificar e intenta enviar de nuevo.`, 's5fc35a09a85fe63e': `Cerrar imagen`, +'s60cfa5a63f8033a8': `Agregar respuesta`, 's63ce0636351bf780': `Los resultados de la subida están incompletos. Inténtalo de nuevo.`, 's63efea23113001ee': `No se pudo guardar. Se ha conservado tu borrador.`, 's64ef2a6c2dd1d3d1': `Editar`, 's67749057edb2586b': `Cerrar sesión`, 's6cb61eeccda272d5': `Bloque de código`, -'s6df8f2a4e5b0fe18': `Hacer público`, -'s6f23997fffbecc6a': `Cancelar aprobación`, +'s6ebc9bd6feae62a3': `Cargando…`, 's7098f8d38ffe5e88': `Operación realizada correctamente`, 's7332f94a3fa137c3': `Tu comprobante de envío ha caducado. Comprueba si se envió tu comentario antes de continuar.`, 's7437373e541a8037': `Apodo`, @@ -67,6 +67,8 @@ 's7584ded3d749c75e': `Cargar más`, 's78f95c3c2b1b0512': `Enlace al comentario`, 's79b7935eff98042c': `Las respuestas privadas no son visibles públicamente. Los destinatarios que hayan iniciado sesión pueden verlas; los destinatarios anónimos reciben una notificación por correo electrónico si las notificaciones están habilitadas.`, +'s7e5bf5d1b7f08ba4': `Hacer privado`, +'s80f580f4b68fb45d': `Permitir notificación`, 's82665b2ffabc9c0a': `Sitio web`, 's838e512973be01d4': `Actualmente conectado. Después de seleccionar la opción privada, los comentarios solo serán visibles para usted y el administrador del sitio.`, 's84b033b2f7360187': `Por favor, inicie sesión o complete la información primero`, @@ -74,23 +76,25 @@ 's879ba21b93adbbbd': `No se pudo cargar el comentario. Inténtalo de nuevo`, 's8db89619f41917b4': `Fijar`, 's8e176d64f6528d3d': str`El tamaño de la imagen no debe superar ${0} MiB.`, +'s95d18a3cf42dfb23': `Código de verificación…`, 's96668830629e0dfc': `Subir`, +'s967419630f92a823': `Volver a los comentarios`, 's98a5f7789c49dd3f': `En revisión`, 's9f2ed66340f019c6': `Escribir un comentario`, -'sa10fbe3fcd6ae148': `Agregar respuesta`, -'sa82fbb7f361c6f44': `Mostrar respuestas`, +'sa83d431fe6e3e2fd': `Ocultar respuestas`, 'sa862e76e95967816': `Comentario actualizado correctamente`, 'sa8dddacbaa66f8e0': `Por favor, inicie sesión primero`, +'sa96c92e537e246f8': `Hacer público`, 'sb206d700d26b14ff': `Subrayado`, 'sb3d4f79d9d8b71e5': `Enviar comentario`, -'sb490fb4653620603': `Volver a los comentarios`, 'sb5a28aa35006ff08': `Comentario enviado con éxito`, +'sb6990f0585fabb8a': `Cancelar aprobación`, 'sbc0cd0b4dfb7dde1': `Desfijar`, 'sc0ffeb5d53b60209': `Error al subir`, -'sc29d248fb3e5c59b': `Ver todas las respuestas`, 'sc592307ea80f16b9': `Desconocido`, 'sc59a34be5493a0af': `Error al subir. Inténtalo de nuevo.`, 'sc8da3cc71de63832': `Iniciar sesión`, +'scdb4863b090af7a4': `Mostrar respuestas`, 'sd1f44f1a8bc20e67': `Correo electrónico`, 'sd5326f2cc13eb2b8': `Tu comentario anterior se envió. Guarda los cambios antes de continuar.`, 'sd5e242ab9574958a': `Error al comentar, por favor intente más tarde`, @@ -107,5 +111,6 @@ 'sf7c0eba7c822e3d6': `Negrita`, 'sf9709983d4da2c54': `Me gusta`, 'sff08dba77ae75e40': `La carga de imágenes no está disponible. Contacta con el administrador del sitio.`, +'sffb4867aafe0bbda': `Aún no hay comentarios`, }; \ No newline at end of file diff --git a/packages/comment-widget/src/generated/locales/zh-CN.ts b/packages/comment-widget/src/generated/locales/zh-CN.ts index 8dcee95b..10a36d53 100644 --- a/packages/comment-widget/src/generated/locales/zh-CN.ts +++ b/packages/comment-widget/src/generated/locales/zh-CN.ts @@ -21,7 +21,6 @@ 's107ccef507b51f2c': `请完成人机验证`, 's15e33945d13c5176': `上次提交结果正在确认,请稍后重试;图片会被保留。`, 's176fe95698857ad9': `评论或回复已发生变化。请先复制草稿,再重新打开编辑框获取最新版本。`, -'s17c8f8524e36987d': `允许通知`, 's19172ed3838854fc': `复制链接`, 's1c6fefb092506753': `加载评论列表失败,请稍后重试`, 's1d468f888124a55e': `草稿已更改,请重新打开后再重试。`, @@ -42,10 +41,11 @@ 's34239948d1de01f1': `验证暂不可用,点击重试`, 's35c3125c7750681e': `引用`, 's3643189d1abbb7f4': `代码`, +'s3e9219b89d8a899b': `查看全部回复`, 's3fb33d17bad61aa9': `评论成功,请等待审核`, -'s424590dc43eddb44': `设为私密`, 's44851a8adf059eef': `置顶`, 's4c0e15f9073382e6': `获取验证码失败`, +'s4d5f8e719d513987': `回复`, 's4d76c6944ffb0540': `保存失败。该评论可能已被删除,或当前 Halo 版本不支持编辑。你的草稿已保留。`, 's5184a3f3e2f7b603': `当前是匿名状态,选择私密选项后,评论将仅对网站管理员可见。`, 's523eb9043213ff0d': `斜体`, @@ -53,13 +53,13 @@ 's58a3c1ecd4dd06cf': `删除线`, 's59b95f09e3b42060': `人机验证未通过,请重新验证后提交。`, 's5fc35a09a85fe63e': `关闭图片`, +'s60cfa5a63f8033a8': `添加回复`, 's63ce0636351bf780': `上传结果不完整,请重试。`, 's63efea23113001ee': `保存失败,草稿已保留。`, 's64ef2a6c2dd1d3d1': `编辑`, 's67749057edb2586b': `退出登录`, 's6cb61eeccda272d5': `代码块`, -'s6df8f2a4e5b0fe18': `设为公开`, -'s6f23997fffbecc6a': `取消通过`, +'s6ebc9bd6feae62a3': `加载中…`, 's7098f8d38ffe5e88': `操作成功`, 's7332f94a3fa137c3': `提交凭证已失效,请先核对评论是否已提交成功。`, 's7437373e541a8037': `昵称`, @@ -67,6 +67,8 @@ 's7584ded3d749c75e': `加载更多`, 's78f95c3c2b1b0512': `评论链接`, 's79b7935eff98042c': `私密回复不会公开显示。已登录的被回复者可以查看;若启用了邮件通知,匿名被回复者会收到邮件通知。`, +'s7e5bf5d1b7f08ba4': `设为私密`, +'s80f580f4b68fb45d': `允许通知`, 's82665b2ffabc9c0a': `网站`, 's838e512973be01d4': `当前已登录,选择私密选项后,评论将仅对您和网站管理员可见。`, 's84b033b2f7360187': `请先登录或者完善信息`, @@ -74,23 +76,25 @@ 's879ba21b93adbbbd': `评论加载失败,请重试`, 's8db89619f41917b4': `置顶`, 's8e176d64f6528d3d': str`单张图片大小不能超过 ${0} MiB。`, +'s95d18a3cf42dfb23': `验证码…`, 's96668830629e0dfc': `上传`, +'s967419630f92a823': `返回评论列表`, 's98a5f7789c49dd3f': `审核中`, 's9f2ed66340f019c6': `编写评论`, -'sa10fbe3fcd6ae148': `加入回复`, -'sa82fbb7f361c6f44': `显示回复`, +'sa83d431fe6e3e2fd': `收起回复`, 'sa862e76e95967816': `评论更新成功`, 'sa8dddacbaa66f8e0': `请先登录`, +'sa96c92e537e246f8': `设为公开`, 'sb206d700d26b14ff': `下划线`, 'sb3d4f79d9d8b71e5': `提交评论`, -'sb490fb4653620603': `返回评论列表`, 'sb5a28aa35006ff08': `评论成功`, +'sb6990f0585fabb8a': `取消通过`, 'sbc0cd0b4dfb7dde1': `取消置顶`, 'sc0ffeb5d53b60209': `上传失败`, -'sc29d248fb3e5c59b': `查看全部回复`, 'sc592307ea80f16b9': `未知`, 'sc59a34be5493a0af': `上传失败,请重试。`, 'sc8da3cc71de63832': `登录`, +'scdb4863b090af7a4': `显示回复`, 'sd1f44f1a8bc20e67': `电子邮件`, 'sd5326f2cc13eb2b8': `上次评论已提交成功,请先保留修改内容再继续。`, 'sd5e242ab9574958a': `评论失败,请稍后重试`, @@ -107,5 +111,6 @@ 'sf7c0eba7c822e3d6': `粗体`, 'sf9709983d4da2c54': `点赞`, 'sff08dba77ae75e40': `图片上传暂不可用,请联系站点管理员。`, +'sffb4867aafe0bbda': `暂无评论`, }; \ No newline at end of file diff --git a/packages/comment-widget/src/generated/locales/zh-TW.ts b/packages/comment-widget/src/generated/locales/zh-TW.ts index 9ddbd824..dfa80ca0 100644 --- a/packages/comment-widget/src/generated/locales/zh-TW.ts +++ b/packages/comment-widget/src/generated/locales/zh-TW.ts @@ -21,7 +21,6 @@ 's107ccef507b51f2c': `請完成人機驗證`, 's15e33945d13c5176': `上次提交結果正在確認,請稍後重試;圖片會被保留。`, 's176fe95698857ad9': `評論或回覆已發生變更。請先複製草稿,再重新開啟編輯框取得最新版本。`, -'s17c8f8524e36987d': `允許通知`, 's19172ed3838854fc': `複製連結`, 's1c6fefb092506753': `載入評論列表失敗,請稍後重試`, 's1d468f888124a55e': `草稿已變更,請重新開啟後再重試。`, @@ -42,10 +41,11 @@ 's34239948d1de01f1': `驗證暫不可用,點擊重試`, 's35c3125c7750681e': `引用`, 's3643189d1abbb7f4': `程式碼`, +'s3e9219b89d8a899b': `查看所有回覆`, 's3fb33d17bad61aa9': `評論成功,請等待審核`, -'s424590dc43eddb44': `設為私密`, 's44851a8adf059eef': `置頂`, 's4c0e15f9073382e6': `獲取驗證碼失敗`, +'s4d5f8e719d513987': `回覆`, 's4d76c6944ffb0540': `儲存失敗。該評論可能已被刪除,或目前 Halo 版本不支援編輯。你的草稿已保留。`, 's5184a3f3e2f7b603': `目前是匿名狀態,選擇私密選項後,評論將僅對網站管理員可見。`, 's523eb9043213ff0d': `斜體`, @@ -53,13 +53,13 @@ 's58a3c1ecd4dd06cf': `刪除線`, 's59b95f09e3b42060': `人機驗證未通過,請重新驗證後提交。`, 's5fc35a09a85fe63e': `關閉圖片`, +'s60cfa5a63f8033a8': `新增回覆`, 's63ce0636351bf780': `上傳結果不完整,請重試。`, 's63efea23113001ee': `儲存失敗,草稿已保留。`, 's64ef2a6c2dd1d3d1': `編輯`, 's67749057edb2586b': `登出`, 's6cb61eeccda272d5': `程式碼區塊`, -'s6df8f2a4e5b0fe18': `設為公開`, -'s6f23997fffbecc6a': `取消通過`, +'s6ebc9bd6feae62a3': `載入中…`, 's7098f8d38ffe5e88': `操作成功`, 's7332f94a3fa137c3': `提交憑證已失效,請先核對評論是否已提交成功。`, 's7437373e541a8037': `暱稱`, @@ -67,6 +67,8 @@ 's7584ded3d749c75e': `載入更多`, 's78f95c3c2b1b0512': `留言連結`, 's79b7935eff98042c': `私密回覆不會公開顯示。已登入的被回覆者可以查看;若已啟用電子郵件通知,匿名被回覆者會收到電子郵件通知。`, +'s7e5bf5d1b7f08ba4': `設為私密`, +'s80f580f4b68fb45d': `允許通知`, 's82665b2ffabc9c0a': `網站`, 's838e512973be01d4': `目前已登入,選擇私密選項後,評論將僅對您和網站管理員可見。`, 's84b033b2f7360187': `請先登入或者完善資訊`, @@ -74,23 +76,25 @@ 's879ba21b93adbbbd': `留言載入失敗,請重試`, 's8db89619f41917b4': `置頂`, 's8e176d64f6528d3d': str`單張圖片大小不能超過 ${0} MiB。`, +'s95d18a3cf42dfb23': `驗證碼…`, 's96668830629e0dfc': `上傳`, +'s967419630f92a823': `返回留言列表`, 's98a5f7789c49dd3f': `審核中`, 's9f2ed66340f019c6': `撰寫評論`, -'sa10fbe3fcd6ae148': `新增回覆`, -'sa82fbb7f361c6f44': `顯示回覆`, +'sa83d431fe6e3e2fd': `收起回覆`, 'sa862e76e95967816': `評論更新成功`, 'sa8dddacbaa66f8e0': `請先登入`, +'sa96c92e537e246f8': `設為公開`, 'sb206d700d26b14ff': `底線`, 'sb3d4f79d9d8b71e5': `提交評論`, -'sb490fb4653620603': `返回留言列表`, 'sb5a28aa35006ff08': `評論成功`, +'sb6990f0585fabb8a': `取消通過`, 'sbc0cd0b4dfb7dde1': `取消置頂`, 'sc0ffeb5d53b60209': `上傳失敗`, -'sc29d248fb3e5c59b': `查看所有回覆`, 'sc592307ea80f16b9': `未知`, 'sc59a34be5493a0af': `上傳失敗,請重試。`, 'sc8da3cc71de63832': `登入`, +'scdb4863b090af7a4': `顯示回覆`, 'sd1f44f1a8bc20e67': `電子郵件`, 'sd5326f2cc13eb2b8': `上次評論已提交成功,請先保留修改內容再繼續。`, 'sd5e242ab9574958a': `評論失敗,請稍後重試`, @@ -107,5 +111,6 @@ 'sf7c0eba7c822e3d6': `粗體`, 'sf9709983d4da2c54': `讚`, 'sff08dba77ae75e40': `圖片上傳暫不可用,請聯絡網站管理員。`, +'sffb4867aafe0bbda': `尚無留言`, }; \ No newline at end of file diff --git a/packages/comment-widget/src/loading-block.ts b/packages/comment-widget/src/loading-block.ts index 24811693..92e71dd3 100644 --- a/packages/comment-widget/src/loading-block.ts +++ b/packages/comment-widget/src/loading-block.ts @@ -1,10 +1,16 @@ +import { msg } from '@lit/localize'; import { css, html, LitElement } from 'lit'; import './icons/icon-loading'; export class LoadingBlock extends LitElement { override render() { - return html`
+ return html`
+ ${msg('Loading…')}
`; } diff --git a/packages/comment-widget/src/reply-item.ts b/packages/comment-widget/src/reply-item.ts index 4d934eac..c47ddf7a 100644 --- a/packages/comment-widget/src/reply-item.ts +++ b/packages/comment-widget/src/reply-item.ts @@ -187,7 +187,7 @@ export class ReplyItem extends LitElement { .private=${this.comment?.spec.hidden || this.reply?.spec.hidden} .editing=${this.showEditForm} > - -
` )} diff --git a/packages/comment-widget/tests/browser-setup.js b/packages/comment-widget/tests/browser-setup.js index 69ebd048..0daa4f22 100644 --- a/packages/comment-widget/tests/browser-setup.js +++ b/packages/comment-widget/tests/browser-setup.js @@ -2,6 +2,11 @@ import { afterEach, vi } from 'vitest'; import '../var.css'; afterEach(() => { + const url = new URL(location.href); + if (url.searchParams.has('comment-page')) { + url.searchParams.delete('comment-page'); + history.replaceState(history.state, '', url); + } document.body.replaceChildren(); document.documentElement.className = ''; document.documentElement.lang = 'en'; diff --git a/packages/comment-widget/tests/comment-item.browser.test.js b/packages/comment-widget/tests/comment-item.browser.test.js new file mode 100644 index 00000000..4a99f1f9 --- /dev/null +++ b/packages/comment-widget/tests/comment-item.browser.test.js @@ -0,0 +1,46 @@ +import { assert, test } from 'vitest'; +import { mockApi } from './browser-helpers.js'; +import '../src/comment-item.ts'; + +test('reply toggle exposes the form state when replies are inline', async () => { + mockApi(() => + Response.json({ + items: [], + page: 1, + size: 10, + total: 0, + totalPages: 0, + hasNext: false, + hasPrevious: false, + }) + ); + const element = document.createElement('comment-item'); + element.comment = { + metadata: { name: 'inline-reply-toggle' }, + spec: { + content: 'test', + approved: true, + owner: { kind: 'User', name: 'admin' }, + }, + owner: { displayName: 'Admin' }, + stats: { upvote: 0 }, + status: { visibleReplyCount: 0 }, + }; + document.body.append(element); + await element.updateComplete; + + const button = element.shadowRoot.querySelector('.show-replies-button'); + const label = () => button.querySelector('.sr-only').textContent.trim(); + assert.equal(button.getAttribute('aria-expanded'), 'false'); + assert.equal(label(), 'Add Reply'); + + button.click(); + await element.updateComplete; + assert.equal(button.getAttribute('aria-expanded'), 'true'); + assert.equal(label(), 'Cancel reply'); + + element.closeReplyForm(); + await element.updateComplete; + assert.equal(button.getAttribute('aria-expanded'), 'false'); + assert.equal(label(), 'Add Reply'); +}); diff --git a/packages/comment-widget/tests/comment-list.browser.test.js b/packages/comment-widget/tests/comment-list.browser.test.js index c19b3e8d..f7dc8cd9 100644 --- a/packages/comment-widget/tests/comment-list.browser.test.js +++ b/packages/comment-widget/tests/comment-list.browser.test.js @@ -99,3 +99,202 @@ test.each(['success', 'out-of-range', 'error'])( ); } ); + +test('pagination state is read from and written to the URL', async () => { + const requests = []; + const pageData = (page) => ({ + page, + size: 20, + total: 40, + totalPages: 2, + hasNext: page < 2, + hasPrevious: page > 1, + items: [ + { + metadata: { name: `page-${page}` }, + spec: { + content: `page-${page}`, + approved: true, + owner: { kind: 'User', name: 'admin' }, + }, + owner: { displayName: 'Admin' }, + stats: { upvote: 0 }, + }, + ], + }); + mockApi((input) => { + const page = Number( + new URL(String(input), location.href).searchParams.get('page') + ); + requests.push(page); + return new Response(JSON.stringify(pageData(page)), { + headers: { 'Content-Type': 'application/json' }, + }); + }); + history.replaceState(null, '', '?comment-page=2'); + await import('../src/comment-list.ts'); + const element = document.createElement('comment-list'); + element.scrollIntoView = () => {}; + document.body.append(element); + await until(() => element.comments.items[0]?.metadata.name === 'page-2'); + assert.equal(requests[0], 2, 'A linked page must be requested initially'); + + const pagination = element.shadowRoot.querySelector('comment-pagination'); + await pagination.updateComplete; + pagination.shadowRoot.querySelector('.pagination-trigger').click(); + pagination.shadowRoot.querySelectorAll('.pagination-pages button')[0].click(); + await until(() => element.comments.items[0]?.metadata.name === 'page-1'); + assert.equal( + new URL(location.href).searchParams.get('comment-page'), + null, + 'Returning to the first page must remove the URL parameter' + ); +}); + +test('a late response after disconnect does not rewrite the URL', async () => { + let resolvePage2; + mockApi((input) => { + const page = Number( + new URL(String(input), location.href).searchParams.get('page') + ); + if (page === 2) { + return new Promise((resolve) => { + resolvePage2 = () => + resolve( + new Response( + JSON.stringify({ + page: 2, + size: 20, + total: 60, + totalPages: 3, + hasNext: true, + hasPrevious: true, + items: [], + }), + { headers: { 'Content-Type': 'application/json' } } + ) + ); + }); + } + return new Response( + JSON.stringify({ + page, + size: 20, + total: 60, + totalPages: 3, + hasNext: true, + hasPrevious: false, + items: [], + }), + { headers: { 'Content-Type': 'application/json' } } + ); + }); + await import('../src/comment-list.ts'); + const element = document.createElement('comment-list'); + element.scrollIntoView = () => {}; + document.body.append(element); + await until(() => !element.loading); + const pending = element.onPageChange({ detail: { page: 2 } }); + await until(() => resolvePage2); + element.remove(); + resolvePage2(); + await pending; + assert.equal( + new URL(location.href).searchParams.get('comment-page'), + null, + 'A disconnected list must not write its page back to the URL' + ); +}); + +test('a failed initial load does not show the empty state', async () => { + mockApi(() => new Response('{}', { status: 500 })); + await import('../src/comment-list.ts'); + const element = document.createElement('comment-list'); + element.toastManager = { error() {} }; + document.body.append(element); + await until(() => element.loadFailed); + await element.updateComplete; + assert.equal(element.shadowRoot.querySelector('.comment-empty'), null); +}); + +test('a popstate request does not inherit the pending push intent', async () => { + mockApi((input) => { + const page = Number( + new URL(String(input), location.href).searchParams.get('page') + ); + return Response.json({ + page, + size: 20, + total: 0, + totalPages: 1, + hasNext: false, + hasPrevious: page > 1, + items: [], + }); + }); + await import('../src/comment-list.ts'); + const element = document.createElement('comment-list'); + element.scrollIntoView = () => {}; + document.body.append(element); + await until(() => !element.loading); + + // A page click is still in flight when the user goes Back to page 2; the + // server has shrunk to one page, so the request falls back to page 1. + element.comments.page = 4; + element.pageChangeShouldPush = true; + history.replaceState(null, '', '?comment-page=2'); + const push = vi.spyOn(history, 'pushState'); + element.onLocationChange(); + await until(() => element.comments.page === 1 && !element.loading); + + assert.equal( + push.mock.calls.length, + 0, + 'A popstate-driven fetch must replace the current entry, not push' + ); +}); + +test('a reconnected list does not push a stale page intent', async () => { + let page2Calls = 0; + let resolveFirstPage2; + const list = (page) => ({ + page, + size: 20, + total: 40, + totalPages: 2, + hasNext: page < 2, + hasPrevious: page > 1, + items: [], + }); + mockApi((input) => { + const page = Number( + new URL(String(input), location.href).searchParams.get('page') + ); + if (page === 2 && ++page2Calls === 1) { + return new Promise((resolve) => { + resolveFirstPage2 = () => resolve(Response.json(list(2))); + }); + } + return Response.json(list(page)); + }); + await import('../src/comment-list.ts'); + const element = document.createElement('comment-list'); + element.scrollIntoView = () => {}; + document.body.append(element); + await until(() => !element.loading); + + const pending = element.onPageChange({ detail: { page: 2 } }); + await until(() => page2Calls === 1); + const push = vi.spyOn(history, 'pushState'); + element.remove(); + document.body.append(element); + await until(() => element.comments.page === 2 && !element.loading); + + assert.equal( + push.mock.calls.length, + 0, + 'Reconnecting must not push a page intent left over from before disconnect' + ); + resolveFirstPage2(); + await pending; +}); diff --git a/packages/comment-widget/tests/comment-management.browser.test.js b/packages/comment-widget/tests/comment-management.browser.test.js index fb65980d..17896951 100644 --- a/packages/comment-widget/tests/comment-management.browser.test.js +++ b/packages/comment-widget/tests/comment-management.browser.test.js @@ -139,7 +139,7 @@ test('Comment management regression checks', async () => { .shadowRoot.querySelector('comment-management'); await management.updateComplete; management.shadowRoot.querySelector('summary').click(); - actionButton(management, 'Make private').click(); + actionButton(management, 'Make Private').click(); await until(() => item.comment.status.visibleReplyCount === 0); assert( comment.status.visibleReplyCount === 1, @@ -175,7 +175,7 @@ test('Comment management regression checks', async () => { pagedItem.shadowRoot.querySelector('comment-management'); await pagedManagement.updateComplete; pagedManagement.shadowRoot.querySelector('summary').click(); - const unapprove = actionButton(pagedManagement, 'Cancel approval'); + const unapprove = actionButton(pagedManagement, 'Cancel Approval'); unapprove.focus(); unapprove.click(); await until(() => replies.replies[19]?.spec.approved === false); diff --git a/packages/comment-widget/tests/comment-permalink.browser.test.js b/packages/comment-widget/tests/comment-permalink.browser.test.js index bbe2d22c..570d5795 100644 --- a/packages/comment-widget/tests/comment-permalink.browser.test.js +++ b/packages/comment-widget/tests/comment-permalink.browser.test.js @@ -490,7 +490,7 @@ test('collapsing detail replies preserves the expanded list', async () => { expect(repliesOf(widget)).toBe(replies); expect(repliesOf(widget).replies).toHaveLength(2); expect(repliesOf(widget).shadowRoot.textContent).not.toContain( - 'View all replies' + 'View All Replies' ); }); test('returning to the list moves keyboard focus to the list', async () => { diff --git a/packages/comment-widget/tests/quote-badge.browser.test.js b/packages/comment-widget/tests/quote-badge.browser.test.js new file mode 100644 index 00000000..cf417a47 --- /dev/null +++ b/packages/comment-widget/tests/quote-badge.browser.test.js @@ -0,0 +1,47 @@ +import { assert, test } from 'vitest'; +import '../src/reply-item.ts'; + +test('quote badge includes its purpose in the accessible name', async () => { + const quote = { + metadata: { name: 'reply-quoted' }, + spec: { + content: 'quoted', + approved: true, + owner: { kind: 'User', name: 'alice' }, + }, + owner: { displayName: 'Alice' }, + stats: { upvote: 0 }, + }; + const element = document.createElement('reply-item'); + element.comment = { + metadata: { name: 'comment-1' }, + spec: { + content: 'comment', + approved: true, + owner: { kind: 'User', name: 'admin' }, + }, + owner: { displayName: 'Admin' }, + stats: { upvote: 0 }, + }; + element.reply = { + metadata: { name: 'reply-1' }, + spec: { + content: 'replying', + approved: true, + owner: { kind: 'User', name: 'bob' }, + quoteReply: 'reply-quoted', + }, + owner: { displayName: 'Bob' }, + stats: { upvote: 0 }, + }; + element.replies = [quote]; + document.body.append(element); + await element.updateComplete; + + const badge = element.shadowRoot.querySelector('.quote-badge'); + assert.ok(badge); + assert.equal( + badge.textContent.replace(/\s+/g, ' ').trim(), + 'In reply to Alice' + ); +}); diff --git a/packages/comment-widget/tests/upvote.browser.test.js b/packages/comment-widget/tests/upvote.browser.test.js index 7a09e68f..03034aa9 100644 --- a/packages/comment-widget/tests/upvote.browser.test.js +++ b/packages/comment-widget/tests/upvote.browser.test.js @@ -32,7 +32,7 @@ for (const tag of ['comment-item', 'reply-item']) { ) ); const el = await mountItem(); - const button = el.shadowRoot.querySelector('button[aria-label="Upvote"]'); + const button = el.shadowRoot.querySelector('button[aria-pressed]'); button.click(); button.click(); await until(() => releases.length > 0); @@ -43,7 +43,10 @@ for (const tag of ['comment-item', 'reply-item']) { await el.updateComplete; assert.equal(releases.length, 1); assert.equal(el.upvoteCount, 1); - assert.equal(button.textContent.trim(), '1'); + assert.equal( + button.querySelector('.icon-button-text').textContent.trim(), + '1' + ); button.click(); await el.handleUpvote(); assert.equal(releases.length, 1); diff --git a/packages/comment-widget/xliff/es.xlf b/packages/comment-widget/xliff/es.xlf index 93f5b5c7..57cfe428 100644 --- a/packages/comment-widget/xliff/es.xlf +++ b/packages/comment-widget/xliff/es.xlf @@ -94,10 +94,6 @@ Comment submitted successfully, pending review Comentario enviado con éxito, pendiente de revisión - - Add reply - Agregar respuesta - Failed to load comment list, please try again later Error al cargar la lista de comentarios, por favor intente más tarde @@ -146,10 +142,6 @@ Upvote Me gusta - - Show replies - Mostrar respuestas - Private Íntimo @@ -194,10 +186,6 @@ Manage Administrar - - Cancel approval - Cancelar aprobación - Approve Aprobar @@ -346,10 +334,6 @@ Select and copy the link manually Selecciona y copia el enlace manualmente - - Back to comments - Volver a los comentarios - Comment not found or unavailable El comentario no existe o no está disponible @@ -358,10 +342,6 @@ Failed to load comment, please try again No se pudo cargar el comentario. Inténtalo de nuevo - - View all replies - Ver todas las respuestas - Retry Reintentar @@ -374,18 +354,6 @@ Private replies are not publicly visible. Signed-in recipients can view them; anonymous recipients are notified by email when notifications are enabled. Las respuestas privadas no son visibles públicamente. Los destinatarios que hayan iniciado sesión pueden verlas; los destinatarios anónimos reciben una notificación por correo electrónico si las notificaciones están habilitadas. - - Make public - Hacer público - - - Make private - Hacer privado - - - Allow notification - Permitir notificación - IP location Ubicación de IP @@ -394,6 +362,58 @@ Unknown Desconocido + + Allow Notification + Permitir notificación + + + Verification code… + Código de verificación… + + + Back to Comments + Volver a los comentarios + + + Hide Replies + Ocultar respuestas + + + Show Replies + Mostrar respuestas + + + Add Reply + Agregar respuesta + + + No comments yet + Aún no hay comentarios + + + Cancel Approval + Cancelar aprobación + + + Make Public + Hacer público + + + Make Private + Hacer privado + + + View All Replies + Ver todas las respuestas + + + Loading… + Cargando… + + + In reply to + En respuesta a + diff --git a/packages/comment-widget/xliff/zh-CN.xlf b/packages/comment-widget/xliff/zh-CN.xlf index 1b5ded0f..4986f43d 100644 --- a/packages/comment-widget/xliff/zh-CN.xlf +++ b/packages/comment-widget/xliff/zh-CN.xlf @@ -94,10 +94,6 @@ Comment submitted successfully, pending review 评论成功,请等待审核 - - Add reply - 加入回复 - Failed to load comment list, please try again later 加载评论列表失败,请稍后重试 @@ -146,10 +142,6 @@ Upvote 点赞 - - Show replies - 显示回复 - Private 私密 @@ -194,10 +186,6 @@ Manage 管理 - - Cancel approval - 取消通过 - Approve 审核通过 @@ -346,10 +334,6 @@ Select and copy the link manually 请选中并手动复制链接 - - Back to comments - 返回评论列表 - Comment not found or unavailable 评论不存在或暂不可查看 @@ -358,10 +342,6 @@ Failed to load comment, please try again 评论加载失败,请重试 - - View all replies - 查看全部回复 - Retry 重试 @@ -374,18 +354,6 @@ Private replies are not publicly visible. Signed-in recipients can view them; anonymous recipients are notified by email when notifications are enabled. 私密回复不会公开显示。已登录的被回复者可以查看;若启用了邮件通知,匿名被回复者会收到邮件通知。 - - Make public - 设为公开 - - - Make private - 设为私密 - - - Allow notification - 允许通知 - IP location IP 属地 @@ -394,6 +362,58 @@ Unknown 未知 + + Allow Notification + 允许通知 + + + Verification code… + 验证码… + + + Back to Comments + 返回评论列表 + + + Hide Replies + 收起回复 + + + Show Replies + 显示回复 + + + Add Reply + 添加回复 + + + No comments yet + 暂无评论 + + + Cancel Approval + 取消通过 + + + Make Public + 设为公开 + + + Make Private + 设为私密 + + + View All Replies + 查看全部回复 + + + Loading… + 加载中… + + + In reply to + 回复 + diff --git a/packages/comment-widget/xliff/zh-TW.xlf b/packages/comment-widget/xliff/zh-TW.xlf index 8b3758d7..30e72e0d 100644 --- a/packages/comment-widget/xliff/zh-TW.xlf +++ b/packages/comment-widget/xliff/zh-TW.xlf @@ -94,10 +94,6 @@ Comment submitted successfully, pending review 評論成功,請等待審核 - - Add reply - 新增回覆 - Failed to load comment list, please try again later 載入評論列表失敗,請稍後重試 @@ -146,10 +142,6 @@ Upvote 讚 - - Show replies - 顯示回覆 - Private 私密 @@ -194,10 +186,6 @@ Manage 管理 - - Cancel approval - 取消通過 - Approve 審核通過 @@ -346,10 +334,6 @@ Select and copy the link manually 請選取並手動複製連結 - - Back to comments - 返回留言列表 - Comment not found or unavailable 留言不存在或暫時無法查看 @@ -358,10 +342,6 @@ Failed to load comment, please try again 留言載入失敗,請重試 - - View all replies - 查看所有回覆 - Retry 重試 @@ -374,18 +354,6 @@ Private replies are not publicly visible. Signed-in recipients can view them; anonymous recipients are notified by email when notifications are enabled. 私密回覆不會公開顯示。已登入的被回覆者可以查看;若已啟用電子郵件通知,匿名被回覆者會收到電子郵件通知。 - - Make public - 設為公開 - - - Make private - 設為私密 - - - Allow notification - 允許通知 - IP location IP 屬地 @@ -394,6 +362,58 @@ Unknown 未知 + + Allow Notification + 允許通知 + + + Verification code… + 驗證碼… + + + Back to Comments + 返回留言列表 + + + Hide Replies + 收起回覆 + + + Show Replies + 顯示回覆 + + + Add Reply + 新增回覆 + + + No comments yet + 尚無留言 + + + Cancel Approval + 取消通過 + + + Make Public + 設為公開 + + + Make Private + 設為私密 + + + View All Replies + 查看所有回覆 + + + Loading… + 載入中… + + + In reply to + 回覆 + From a0ebe487e281887ce224b714217a5b09cc42921c Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Mon, 2 Nov 2026 18:37:22 +0800 Subject: [PATCH 2/2] Bump Halo dev server to 2.27.0 and plugin devtools to 0.9.0 --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 3993ca23..e11a7288 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java' id "io.freefair.lombok" version "9.2.0" id "com.github.node-gradle.node" version "7.1.0" apply false - id "run.halo.plugin.devtools" version "0.8.0" + id "run.halo.plugin.devtools" version "0.9.0" } group = 'run.halo.comment.widget' @@ -141,6 +141,6 @@ tasks.named('clean') { } halo { - version = "2.26.0" + version = "2.27.0" debug = true }