Skip to content

feat(web): improve task controls and keyboard reordering#25

Merged
johannesjo merged 16 commits into
mainfrom
task/beautify-05-task-interactions
Jul 21, 2026
Merged

feat(web): improve task controls and keyboard reordering#25
johannesjo merged 16 commits into
mainfrom
task/beautify-05-task-interactions

Conversation

@johannesjo

@johannesjo johannesjo commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add specific accessible names to task controls (the checkbox is named after the task; delete names its target)
  • add keyboard-reachable task reordering via the row's ⋯ menu, announced on success
  • serialize reorder persistence and roll back failed updates
  • make toggle outro animations cancellable, and hold focus through remounts, failures, and reorders
  • expose list-card headings and collapse relationships consistently
  • run npm run check before the audit gate in CI, so typecheck/lint/format actually report

Why

Pointer reordering had no equivalent keyboard path, and asynchronous task updates could race animations or leave focus in an unstable position.

Keeping focus through a reorder

Worth recording, because the first two attempts at this were wrong.

Keyboard reorder is only reachable through the ⋯ menu, and desktop CSS used to
display: none that trigger unless the row was reorderable. An unrendered
element cannot hold focus, so the trigger vanished out from under the focus the
closing menu had just returned to it.

The first fix kept the move actions mounted while a save was in flight, so the
trigger could not lose its .reorderAvailable marker mid-interaction. That was
necessary but not sufficient, and the invariant was not holdable anyway: the
marker derives from props.items, so a collaborator's SSE echo could drop it.

This PR deletes the display: none rule instead of defending it. .moreButton
already sits in the opacity-reveal group, whose comment states the rule that was
being violated — buttons stay in layout, hit-testable, and focusable. That
removes canReorder(), the .reorderAvailable class, and the failure mode.

Focus still has to be handed back explicitly, because the optimistic store write
reorders the row synchronously and Solid's <For> re-inserts it — re-parenting a
node blurs its focused descendant. It is restored on both edges of the
commit: after the optimistic write, and again after the response, because a
failure rolls the position back and that second write re-parents the row again.

Failures are reported by the toast alone. Toast is itself role="status", so
also writing the live region read every failure to a screen reader twice.

Impact

Tasks can be reordered from the keyboard, save failures are reported and rolled
back, and focus stays on the row being manipulated throughout — including when
the save fails.

Desktop rows that were previously not reorderable (done rows, resting recurring
rows, single-item lists) now show the ⋯ trigger, revealed on hover/focus like
the four buttons beside it. That is one extra tab stop on those rows, and the
cost of the trigger never being able to disappear under focus.

Validation

  • 217 web unit tests, including a regression test that fails if either focus
    restore is removed
  • 202 Playwright flows across desktop and mobile viewports
  • typecheck, ESLint, Prettier, production image smoke

The reorder tests previously mocked the store's moveItem as a no-op, so no row
ever moved and focus was never at risk — which is how the original bug survived
the whole suite. The mocks now reposition for real, so future reorder tests
exercise the real DOM movement by default rather than opting in.

Follow-ups

Deferred findings from review are tracked separately: the module-scope
reorderPending having no release path, memo identity churn rebuilding open
menus, the touch-only display: none rule carrying the same latent bug this PR
removes, and the duplicated delete-with-undo handler.

Base: main (the rest of the beautify stack has landed).

@johannesjo
johannesjo force-pushed the task/beautify-04-project-shell branch from cf37a85 to f09aec4 Compare July 21, 2026 12:52
Base automatically changed from task/beautify-04-project-shell to main July 21, 2026 15:07
johannesjo and others added 9 commits July 21, 2026 17:25
Gating onMoveUp/onMoveDown on reorderPending() dropped .reorderAvailable
from the ⋯ trigger mid-flight, which desktop CSS display:none's — pulling
the element out from under the focus Popover had just returned to it, so
a keyboard reorder ended with focus on <body>. Offer the actions by
position only and let commitReorder's guard refuse the second commit.

A refused commit now toasts instead of no-oping: a drag dropped during a
pending reorder had its DOM already reverted, so the row snapped back
with no explanation. The toast is role="status", so it covers the
keyboard path too.

Also hover/focus-reveal the ⋯ on desktop like the other row actions
(it was permanently visible), and neutralize the new <h2>'s UA type so
the collapsed "· N" count stops inheriting bold.
role="checkbox" + aria-checked already announce "checkbox, not checked",
so "Mark X complete" duplicated the state and flipped the accessible
name as the row toggled. Name it after the task instead, per the APG
checkbox pattern.

Also flatten captureFocusFromRow's restore closure: a four-level nested
ternary and a duplicated row lookup become two named helpers
(checkboxIn / sourceControlIn) and a rowById lookup. No behaviour change.
npm audit --audit-level=high has been failing on this branch, and it ran
BEFORE npm run check — so typecheck, lint and format:check never
executed. Any lint or formatting regression could land unnoticed.

Bump the two high-severity dev-only transitives (js-yaml 4.2.0 -> 4.3.0
via eslint, shell-quote 1.8.4 -> 1.9.0 via concurrently 9.2.4);
lockfile-only, no majors. Then move the audit after npm run check with
if:always(), so a newly published advisory can no longer mask the code
checks on an unrelated run.

The remaining 4 moderate esbuild advisories (via drizzle-kit) sit below
the high threshold and would need a drizzle-kit major.
The keyboard reorder path had no e2e coverage, and the regression it
guards is invisible to jsdom: the focus loss came from a CSS rule
(.moreButton:not(.reorderAvailable) { display: none }) unrendering the
focused ⋯ trigger, and unit tests apply no CSS. The PATCH is delayed via
page.route so the in-flight window survives a style recalc.

NOT YET RUN — the sandbox cannot reach docker-published host ports, so
no local database was available. Typecheck and format pass; CI's e2e job
is the first real execution.
ListCard's onDeleteItem became Promise<boolean> so a failed delete can keep
focus on the row. ChecklistCard's twin handler already returned it; the hero
list's copy in Project.tsx still returned void, which only surfaced once the
branch rebased onto main and 'npm run check' actually ran.

Also drops a duplicate .titleGroup comment: main's merged #27 added the same
heading reset, so this branch no longer needs to touch that file.
The previous fix kept the reorder actions mounted so the desktop rule
'.moreButton:not(.reorderAvailable) { display: none }' could not unrender the
menu trigger under focus. That was necessary but not sufficient, and the
invariant it relied on was not actually holdable:

- The optimistic store write reorders the row synchronously, and <For>
  re-inserts it. Re-parenting a node blurs its focused descendant, so focus
  hit <body> on every keyboard move, long before the PATCH resolved.
- .reorderAvailable is derived from props.items, so a collaborator's SSE echo
  could drop it mid-interaction and display:none the trigger anyway.

Drop the display:none rule instead of defending it: .moreButton already sits in
the opacity reveal group, whose comment states the rule this violated --
buttons stay in layout, hit-testable, and focusable. That removes canReorder(),
the .reorderAvailable class, and the whole failure mode. Focus is then handed
back to the trigger after the synchronous write. Keep the trigger visible while
its own menu is open, since the portal puts focus outside :focus-within.

The existing tests mocked moveItem to a no-op, so no row ever moved and focus
was never at risk; the new test reorders for real and fails without the fix.
Rework the e2e test onto a released gate rather than a 600ms timer, drive it
from the keyboard, and assert focus after the response lands.
Every reorder test mocked moveItem/setItemPosition as no-ops, so no row ever
moved. That is how a focus bug that broke the feature's central promise on
every keyboard move survived the whole suite: with a static list, focus was
never at risk, and the assertions could only ever check that the API was
called with the right arguments.

Have renderCard drive the card from a signal and give the mocks a real
reposition-and-resort, mutating items in place so identity survives the way
produce() does in store.ts. <For> then re-inserts rows exactly as it does in
the app, and any future reorder test exercises the real DOM movement by
default rather than opting in.

Three tests needed adjusting because they had been asserting against a list
that never changed:
- the refusal test picked Move up on a row that is first once the optimistic
  move lands; it now moves the row that still has somewhere to go
- two announcement assertions raced the extra render and now wait for it
- the focus test waits for its commit to settle, because reorderPending is
  module scope and an in-flight move leaks into the next test
@johannesjo
johannesjo force-pushed the task/beautify-05-task-interactions branch from cbcf129 to c4d3eea Compare July 21, 2026 17:00
Toast is role="status", so raising a toast AND writing the live region read
every reorder failure to a screen reader twice. Let the toast own failures and
the live region own success. That also collapses commitReorder's
boolean | null tri-state: refused and failed are now both plain false, since
the only caller treats them identically -- both have already been toasted.

Refresh /_styleguide, which still described the ⋯ menu as touch-only. It is on
pointer devices too now, because keyboard reorder has no inline equivalent.
The demo rows passed no move handlers, so the menu could not show Move up /
Move down at all; give them positional availability the way ListCard computes
it, folding four near-identical blocks into a For.

Use if: ${{ !cancelled() }} for the audit step -- it expresses the actual
intent (never let the checks above short-circuit the audit) without also
burning a step on cancelled runs.
The focus restore ran once, after the optimistic write. A failed move rolls the
position back -- a second synchronous store write, so <For> re-inserts the row a
second time and blurs the trigger again, with nothing to catch it. Every focus
assertion sat on the success path, so the suite missed it.

Restore on both edges, and take the trigger from the argument Menu already hands
to onSelect instead of sniffing document.activeElement: the node is then known
rather than inferred from Popover's focus-return ordering. The <body> guard
stays, so a user who deliberately focused elsewhere during the request keeps it.

Also from review:
- the styleguide demo gave move actions to its two checked rows, which ListCard
  excludes from reordering, while claiming to mirror it
- reorderIndex's comment justified the position-only rule with a scenario that
  cannot happen (Menu closes before onSelect runs); state the real reason
- drop an assertion that could not fail, rename a describe that still called the
  menu mobile-only, and assert the e2e menu focus instead of setting it
From an accessibility/correctness/simplicity review round. Two of these were
reproduced with probes before being fixed, and both are the same shape as the
earlier bugs: a path handled on one branch and not its sibling.

- justArrived was only dropped when the outro was still running, so a toggle
  whose PATCH fails after the outro finishes orphans the marker and the row
  replays its entrance animation on the next remount. Drop it unconditionally.
- moveByKeyboard held a raw trigger node, so a concurrent remote edit that
  remounts the row mid-save left focus stranded on <body>. Re-query by item id,
  the same way ListItem#captureFocusFromRow already does.
- Tab out of an open menu walked past the portal into nothing: focus on <body>,
  menu still open, its backdrop swallowing every click. Close on Tab and keep
  the items out of the tab sequence (APG). Menu had no tests; it has three now.
- Toasts each mounted their own role="status" together with its text, which is
  the unreliably-announced pattern — and they are the only channel for reorder
  failures. Move the live region to the always-mounted host.

Also: two comments still called the ⋯ trigger mobile-only, one described the
fade-out as keyboard-specific when the scrim makes it happen for pointers too,
the opacity-not-display rationale was stated in four places (the CSS is where
re-adding display:none would be tempting, so it keeps it), and
sourceControlIn's last fallback was unreachable — every focusable control in a
row carries a data-testid.
npm audit --audit-level=high went red on a fresh advisory, with no code change
on our side: fast-uri <3.1.2 host confusion via failed IDN canonicalization
(GHSA-4c8g-83qw-93j6). Both bumps are patch-level and within the declared
ranges, so this is what a fresh install would resolve to anyway; hono 4.12.31
also clears three moderate advisories and supersedes dependabot #6.

Only the dev-only esbuild/drizzle-kit chain remains, at moderate, with no
non-breaking fix — below the gate's threshold.

This is exactly the case the audit step's comment describes, and the reordering
earned itself: npm run check ran and reported green on the same CI run instead
of being short-circuited by the audit.
@johannesjo
johannesjo force-pushed the task/beautify-05-task-interactions branch from c0247f0 to d329fec Compare July 21, 2026 20:30
@johannesjo
johannesjo marked this pull request as ready for review July 21, 2026 20:38
Moving role="status" off Toast and onto the Project toast host silenced this
one, which renders a Toast outside that host. Its own host was inside the
<Show>, so putting the role there would recreate the problem the move was
meant to fix — a live region inserted in the same tick as its content is
unreliably announced. Hoist the host out instead, so the region is always
mounted and only its content changes.
Prevent Enter's default action from reopening the reminder picker after focus returns to its trigger. Update the assignee regression to assert the identity-preserving row behavior directly.
@johannesjo
johannesjo merged commit 5a0af3b into main Jul 21, 2026
5 checks passed
@johannesjo
johannesjo deleted the task/beautify-05-task-interactions branch July 21, 2026 23:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant