Preserve focus on star/unstar & watch/unwatch buttons after click (#11932)

Fixes https://codeberg.org/forgejo/forgejo/issues/11880.

Adding `hx-on::after-settle="this.querySelector('button').focus()"` restores focus after the content has been swapped and the DOM has been setled. I tried `hx-on::after-swap` first since it's mentioned more often in https://github.com/bigskysoftware/htmx/issues/1869, but it didn't work.

The demo attached in `focus.mp4` runs through a series of repeated clicks on both buttons. You can hear the screen reader announce the button's new label when focus is restored.

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [ ] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

*The decision if the pull request will be shown in the release notes is up to the mergers / release team.*

The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11932
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Co-authored-by: Henry Catalini Smith <henry@catalinismith.se>
Co-committed-by: Henry Catalini Smith <henry@catalinismith.se>
This commit is contained in:
Henry Catalini Smith 2026-04-08 02:32:14 +02:00 committed by Mathieu Fenniak
parent dd968f147d
commit 8a6d76cff4
3 changed files with 16 additions and 2 deletions

View file

@ -1,4 +1,4 @@
<form hx-boost="true" hx-target="this" method="post" action="{{$.RepoLink}}/action/{{if $.IsStaringRepo}}un{{end}}star">
<form hx-boost="true" hx-target="this" method="post" action="{{$.RepoLink}}/action/{{if $.IsStaringRepo}}un{{end}}star" hx-on::after-settle="this.querySelector('button').focus()">
<div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.star_guest_user"}}"{{end}}>
<button type="submit" class="ui compact small basic button"{{if not $.IsSigned}} disabled{{end}} aria-label="{{if $.IsStaringRepo}}{{ctx.Locale.Tr "repo.unstar"}}{{else}}{{ctx.Locale.Tr "repo.star"}}{{end}}">
{{if $.IsStaringRepo}}

View file

@ -1,4 +1,4 @@
<form hx-boost="true" hx-target="this" method="post" action="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}un{{end}}watch">
<form hx-boost="true" hx-target="this" method="post" action="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}un{{end}}watch" hx-on::after-settle="this.querySelector('button').focus()">
<div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.watch_guest_user"}}"{{end}}>
<button type="submit" class="ui compact small basic button"{{if not $.IsSigned}} disabled{{end}} aria-label="{{if $.IsWatchingRepo}}{{ctx.Locale.Tr "repo.unwatch"}}{{else}}{{ctx.Locale.Tr "repo.watch"}}{{end}}">
{{if $.IsWatchingRepo}}

View file

@ -12,6 +12,8 @@ import {expect} from '@playwright/test';
import {test} from './utils_e2e.ts';
import {screenshot} from './shared/screenshots.ts';
test.use({user: 'user2'});
test('Language stats bar', async ({browser}) => {
// This test doesn't need JS and runs a little faster without it
const context = await browser.newContext({javaScriptEnabled: false});
@ -55,3 +57,15 @@ test('Branch selector commit icon', async ({page}) => {
await expect(page.locator('.branch-dropdown-button svg.octicon-git-commit')).toBeVisible();
await expect(page.locator('.branch-dropdown-button')).toHaveText('65f1bf27bc');
});
test('Star button focus retention', async ({page}) => {
const response = await page.goto('/user2/repo1');
expect(response?.status()).toBe(200);
const starButton = page.locator('button[aria-label="Star"], button[aria-label="Unstar"]');
await starButton.click();
await expect(
page.locator('button[aria-label="Star"]:focus, button[aria-label="Unstar"]:focus'),
).toBeVisible();
});