Skip to content

Development: Use real buttons and links instead of click-handler divs - #2529

Open
az108 wants to merge 17 commits into
mainfrom
chore/semantic-interactive-elements
Open

Development: Use real buttons and links instead of click-handler divs#2529
az108 wants to merge 17 commits into
mainfrom
chore/semantic-interactive-elements

Conversation

@az108

@az108 az108 commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Checklist

General

Client

  • Important: I implemented the changes with a very good performance, prevented too many (unnecessary) REST calls and made sure the UI is responsive, even with large data (e.g. using paging).
  • I strictly followed the principle of data economy for all client-server REST calls.
  • I strictly followed the client coding and design guidelines.
  • I added multiple integration tests (Vitest) related to the features (with a high test coverage), while following the client test guidelines.
  • I documented the TypeScript code using JSDoc style.

Motivation and Context

Closes #2395.

Across the app many templates implemented the same accessibility pattern by hand on non-semantic elements (<div>, <p>, <article>): role="button" + tabindex="0" + (click) + (keydown.enter), sometimes with an extra (keydown.space). That duplication made behaviour inconsistent — one row also handled Space, the next didn't; <a> tags without href had to fake Enter activation themselves — and it offers a worse experience than a real <button> or <a>: no open-in-new-tab for links, no native keyboard contract, no automatic focus ring.

Description

The audit found 23 occurrences across 16 files, in three buckets.

Button-like (10) — now <button type="button">, with a small .btn-bare class in global.scss that strips the button chrome so the existing card and row styling survives. Affected: sidebar-button, image-upload-button, upload-button, rating segments, string-input helper-text link, selectable-slot-card, interview-process-card, document-dialog rows, the profile-picture-settings avatar trigger, and the login / registration switch-view links, which were <a> without href and so never links at all.

Link-like (4) — now <a [routerLink]>. Affected: the footer's imprint, privacy and about-us links, whose navigation methods on FooterComponent are gone, and job-card, where an onViewDetails / onKeyDown pair became [routerLink]="detailLink()".

Interactive content inside (5) — kept as <div> with a new [jhiClickable] directive that owns the role, the tabindex and Enter/Space activation. These are the cases where the element contains another interactive element, which the spec forbids inside a <button> or <a>: interviewee-card, interviewee-section, assign-applicant-modal, upcoming-interview-card, and the image card in job-creation-form.

click-events-have-key-events was given ignoreWithDirectives: ['jhiClickable'], so the directive is a first-class way to satisfy the rule instead of inline keyboard handlers.

What changed during review

Testing this turned up a set of keyboard problems, some of which predate the PR:

  • The directive was swallowing keys from nested elements. Key events bubble, so Enter on a nested delete button ran the wrapper's handler, which cancelled the button's own activation and clicked the wrapper instead — tabbing to the trash icon on an image card selected the image rather than deleting it. It now ignores key presses that did not happen on the host, which is the very case it exists for.
  • The compliance banner needed its delegation back. The link inside that translated message is an anchor without an href, so it never fires a click of its own; the wrapper used to catch the bubbled key press and recognise the link by its class. Going through the directive replaced that with a click on the wrapper, so the class never matched. It is a role="presentation" delegator again, and no longer takes a tab stop of its own.
  • Confirmation dialogs left focus behind the mask. PrimeNG focuses the first control of its own header, content or footer, and a headless dialog renders none of those, so the dialog could not be reached by keyboard at all. It now moves focus itself and hands it back to whatever opened it on close. Ours is the only headless dialog in the app; every other popup already behaved.
  • The delete button on an image card is invisible until the card is hovered. opacity-0 still leaves an element focusable, so tabbing landed on a control nobody could see. It now appears on focus as well.
  • Both candidate lists cost one tab stop per person, which is a long way round when a search returns twenty-five professors — and the add-members rows cost two each, the row plus the checkbox inside it. Both are now listboxes: one tab stop, arrows to move, Home and End for the ends, Enter or Space to pick, Tab to leave. Rows are options that report whether they are selected, and the add-members checkbox now only shows the state its row already announces.
  • Layout fixes: the sidebar button stretches so its highlight covers the whole row; the upload tile fills its grid cell again, having collapsed to the size of its icon when a block div became an inline-block button; and .btn-bare is down to the two declarations Tailwind does not already reset, inside @layer components so utilities still win.

job-card no longer activates on Space. That is deliberate: it is a real link now, and links activate on Enter while Space scrolls the page.

Steps for Testing

Prerequisites: log in and visit each affected surface.

Mouse, unchanged behaviour

  1. Sidebar items, footer links, login and register switch links, upload buttons, rating segments, the string-input helper link.
  2. Job overview cards — and note that right-click, open in new tab now works, which it could not before.
  3. Job creation image picker, document dialog list, profile picture editor, interview cards, slot cards, interviewee rows, filter multiselect.

Keyboard

  1. Tab through each surface: everything clickable is still reachable, Enter activates, Space activates buttons but not links.
  2. On an image card, tab to the trash icon. It should become visible on focus, and Enter should open the delete dialog rather than selecting the image.
  3. With that dialog open, Tab should move between Cancel and the confirm button. Close it and focus should land back on the trash icon, not at the top of the page.
  4. Do the same for deleting an uploaded file in the upload component.
  5. In the job creation compliance banner, tab to the review link inside the message and press Enter. It should jump to the first step.
  6. Open the research group creation form as an admin, search for professors, and tab into the results. The list should be a single tab stop: arrows move between candidates, Home and End jump to the ends, Enter picks one, and Tab continues to the next field.
  7. Repeat in the Add members dialog. Each person should be one stop rather than two, and Space should toggle selection.
  8. Search again for something with fewer results while further down the list, then tab back in — the list should still be reachable.

Review Progress

Code Review

  • Code Review 1

Manual Tests

  • Mouse activation is unchanged everywhere
  • Enter activates; Space activates buttons but not links
  • Nested buttons inside clickable wrappers act for themselves
  • Confirmation dialogs take focus on open and give it back on close
  • The trash icon on an image card is visible when focused
  • Both candidate lists are one tab stop and move under the arrow keys
  • No visual regressions on cards, rows or icon buttons

Test Coverage

Client

Class/File Line Coverage Lines Expects Ratio
interviewee-card.component.ts 0.00% 61 11 18.0
interviewee-section.component.ts 0.00% 360 41 11.4
assign-applicant-modal.component.ts 0.00% 142 ? ?
upcoming-interview-card.component.ts 0.00% 43 9 20.9
job-creation-form.component.ts 0.00% 1256 96 7.6
job-card.component.ts 100.00% 78 8 10.3
footer.component.ts 0.00% 28 9 32.1
checkbox.component.ts 0.00% 31 ? ?
confirm-dialog.ts 95.91% 83 11 13.3
image-upload-button.component.ts 0.00% 121 8 6.6
sidebar-button.component.ts 95.23% 37 7 18.9
research-group-creation-form.component.ts 0.00% 396 ? ?
clickable.directive.ts 100.00% 33 12 36.4
listbox.util.ts 100.00% 19 7 36.8
research-group-add-members.component.ts 0.00% 201 40 19.9
jobs-preview-section.component.ts 100.00% 51 12 23.5

Last updated: 2026-07-28 14:53:04 UTC

The codebase had dozens of `<div>`/`<p>`/`<article>` elements made
clickable by hand with `role=button` + `tabindex=0` + `(click)` +
`(keydown.enter)`. This converted every case that can be expressed
semantically to a real `<button type="button">` (with a small
`.btn-bare` reset class), real `<a [routerLink]>`, or — where an
interactive descendant blocks both — a new `jhiClickable`
directive that owns the `role`/`tabindex` host attributes and the
Enter/Space activation. Dead `stopPropagation`-only wrappers around
delete-button overlays were removed; one remaining propagation guard
keeps its no-role/no-tabindex form so the focus ring is not stolen
from the inner button.

Closes #2395

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added client Pull requests that update TypeScript code. (Added Automatically!) tests labels May 14, 2026
@codacy-production

codacy-production Bot commented May 14, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 3 high

Alerts:
⚠ 3 issues (≤ 0 issues of at least minor severity)

Results:
3 new issues

Category Results
Security 3 high

View in Codacy

🟢 Metrics 49 complexity

Metric Results
Complexity 49

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@github-actions

Copy link
Copy Markdown
Contributor

📊 Client Test Coverage Too Low

🔍 View coverage locally:

npm run test:ci
open build/test-results/vitest/coverage/index.html

🌐 View coverage from GitHub:
Download the "coverage-report-client" artifact from this workflow run.

…ctive-elements

# Conflicts:
#	src/main/webapp/app/shared/components/atoms/upload-button/upload-button.component.html
#	src/main/webapp/app/shared/components/molecules/document-dialog/document-dialog.html
az108 and others added 2 commits May 21, 2026 15:03
…ctive-elements

# Conflicts:
#	src/main/webapp/app/layouts/footer/footer.component.html
#	src/main/webapp/app/layouts/footer/footer.component.ts
#	src/main/webapp/app/shared/components/atoms/upload-button/upload-button.component.html
@github-actions

Copy link
Copy Markdown
Contributor

There hasn't been any activity on this pull request recently. Therefore, this pull request has been automatically marked as stale and will be closed if no further activity occurs within seven days. Thank you for your contributions.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

There hasn't been any activity on this pull request recently. Therefore, this pull request has been automatically marked as stale and will be closed if no further activity occurs within seven days. Thank you for your contributions.

@github-actions

Copy link
Copy Markdown
Contributor

There hasn't been any activity on this pull request recently. Therefore, this pull request has been automatically marked as stale and will be closed if no further activity occurs within seven days. Thank you for your contributions.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

There hasn't been any activity on this pull request recently. Therefore, this pull request has been automatically marked as stale and will be closed if no further activity occurs within seven days. Thank you for your contributions.

@github-actions

Copy link
Copy Markdown
Contributor

There hasn't been any activity on this pull request recently. Therefore, this pull request has been automatically marked as stale and will be closed if no further activity occurs within seven days. Thank you for your contributions.

@github-actions

Copy link
Copy Markdown
Contributor

There hasn't been any activity on this pull request recently. Therefore, this pull request has been automatically marked as stale and will be closed if no further activity occurs within seven days. Thank you for your contributions.

az108 and others added 2 commits July 25, 2026 17:39
Resolve the conflict in filter-multiselect.html.

Main has since reworked this component's keyboard handling: the separate
selected and unselected option loops were replaced by a single listbox whose
rows carry role="option" and tabindex="-1", with arrow-key navigation and
aria-activedescendant driven centrally from the component host. This branch
still carried the older two-loop markup, so keeping its side would have
rendered every option twice.

Took main's structure, which means this branch's jhiClickable changes to this
one component are dropped. That is deliberate rather than incidental:

- On the option rows, jhiClickable sets tabindex="0", which would put every
  option back in the tab order. Removing them from it is exactly what the
  recent keyboard-navigation fix did, after a reviewer reported Tab walking
  past the options into the next dropdown.
- On the trigger, jhiClickable turns Enter and Space into a click. The host
  key handler already handles those, and the two together left no option
  highlighted after opening, so a following Enter selected nothing. Verified
  by driving the component: with jhiClickable the focused index stayed at -1
  and nothing was selected, with role="button" it highlights the first option
  and Enter selects it.

The directive import became unused here and was removed. The directive itself,
its spec, and its six other usages are untouched.

Verified on the merged tree: typecheck, 2023 client tests, eslint (0 errors),
a11y lint with --max-warnings=0, and the production build.

Co-Authored-By: Claude <noreply@anthropic.com>

@Cathy0123456789 Cathy0123456789 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested locally. Was this intended to remove the marking?
Before:
Image
After:

Image

The active and hover highlight disappeared from the sidebar items after the
switch to native buttons.

.btn-bare was declared outside any cascade layer while Tailwind emits its
utilities inside @layer utilities. An unlayered rule outranks every layered one
whatever its specificity, so the reset won against the utilities sitting on the
same element and its background, padding, colour, font and text-align were
applied instead of theirs. Moving the rule into the components layer, which the
generated stylesheet orders before utilities, restores the intended precedence.

The sidebar was the visible symptom, but the same reset was quietly beating
utilities on all twelve elements using it: padding on the upload button and the
slot cards, background and border on the interview process card, colour and
weight on the login and registration links, alignment in the document dialog.

Confirmed from the compiled stylesheet rather than by eye: .btn-bare now resolves
inside the components layer while .bg-primary-hover-outlined stays in utilities.

Added a guard on the declaration, since nothing else can catch this. The class is
applied either way, so a component test still passes while the styling is gone,
and the failure is only visible on screen. The guard was checked to fail with the
rule moved back out of the layer.

Verified: 2028 client tests, typecheck, eslint with no errors, a11y lint, the
production build and prettier.

Co-Authored-By: Claude <noreply@anthropic.com>
@az108
az108 requested a review from Cathy0123456789 July 25, 2026 17:09

@Cathy0123456789 Cathy0123456789 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀

image

…e row

The highlight came back with the cascade fix but only wrapped the label instead
of filling the row.

The element the highlight paints is now a button rather than a div. A div with
display flex is a block level flex container and fills the width it is given,
while a button is a form control and keeps shrink to fit sizing even when its
display is changed, so it collapsed onto its content. The surrounding host was
already sized correctly in both places the component is used, through flex-1 or
w-full, so only the inner button needed to be told to fill it.

It now takes w-full unless it is the collapsed icon-only variant, which keeps
its fixed w-11 square. The two are mutually exclusive rather than both applied,
since between two width utilities the winner would come from their order in the
generated stylesheet rather than from the template.

The collapsed condition was written out twice in the template and is now a named
computed, which is what the three class bindings share.

Checked the other elements converted to buttons: the remaining ones without a
width are inline text links and a fixed size avatar, where shrink to fit is what
they want.

Covered by tests for all three shapes, confirmed to fail without the change.

Verified: 2031 client tests, typecheck, eslint with no errors, a11y lint, the
production build and prettier.

Co-Authored-By: Claude <noreply@anthropic.com>
@az108
az108 requested a review from Cathy0123456789 July 26, 2026 10:02
…dy reset

Six of its nine declarations repeated Tailwind's preflight, which already gives
buttons margin, padding and border from the universal reset and font, colour and
background from the form element rule. Repeating them bought nothing and only
widened the set of properties the class could take away from the utilities on the
same element, which is how the sidebar lost its highlight and its padding.

What is left is what preflight does not cover: buttons come out centred, and
since v4 they carry a default cursor rather than a pointer. Ten of the eleven call
sites already pass cursor-pointer themselves, so that one is close to redundant
too, but rating.component relies on it.

appearance: none went with the rest. It was overriding preflight's
appearance: button, and with background and border already flat there is no native
chrome left for it to suppress.

Confirmed against the compiled stylesheet: the rule is now two declarations, the
preflight rules that cover the removed ones are still present, and btn-bare stays
in the components layer with the utilities after it.

Verified: 2031 client tests, typecheck, eslint with no errors, a11y lint, the
production build and prettier.

Co-Authored-By: Claude <noreply@anthropic.com>

@Cathy0123456789 Cathy0123456789 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested locally and code reviewed a part, saw this:

Something seems to have broken with the size of the empty input at the bottom 👀

Before:

Image

After:

Image

Comment thread src/main/webapp/app/job/job-creation-form/job-creation-form.component.html Outdated
…ents

Keyboard events bubble, so the clickable directive was swallowing Enter
and Space from anything focusable inside its host: it cancelled the key
press and clicked the host instead. Tabbing to the trash icon on an image
card and pressing Enter therefore selected the image rather than deleting
it. Nested buttons and links are the reason the directive exists, so it
now ignores key presses that did not happen on the host itself.

The compliance banner needed its delegation back on top of that. The link
inside the translated message is an anchor without an href, so it never
fires a click of its own; the wrapper used to catch the bubbled key press
and recognise the link by its class. Going through the directive replaced
that with a click on the wrapper, so the class never matched. The wrapper
is a delegator rather than a control, so it no longer takes a tab stop of
its own either.

The upload tile collapsed to the size of its icon because a button is
inline-block where the div it replaced was block and filled its grid cell.

The same conversion also dropped the translation on the upload button's
aria-label in favour of an English string.

Co-Authored-By: Claude <noreply@anthropic.com>
@az108

az108 commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for testing this properly — all four are addressed in 0644566, and I've replied inline on each.

The shrunken upload tile. jhi-image-upload-button went from a <div> to a <button>, but its container classes never set a width. A div is block and filled its grid cell; a button is inline-block and shrinks to fit, so the tile collapsed to the size of the + icon. It now carries block w-full.

I checked the other ~14 div-to-button conversions in this PR for the same thing — the rest all carry an explicit w-full, flex-1, grow or a fixed size, so this was the only one that collapsed.

One more you didn't flag: the same conversion replaced the translated aria-label on upload-button with a hardcoded English aria-label="Select file to upload". Restored the entity.upload.aria.selectFileToUpload binding — the key was still there in both locales.

Summary of the four:

Status
Delete image by tabbing Fixed — jhiClickable was swallowing Enter/Space from nested buttons
Compliance banner link Fixed — needed its keydown delegation back, not just the directive fix
Upload tile size Fixed — block w-full
Space on the job card Left as is — native links activate on Enter; see the inline reply and shout if you'd rather have it back

Gates green: 2034 client tests, typecheck, ESLint, a11y lint, prettier, production build.

…ctive-elements

# Conflicts:
#	src/main/webapp/app/shared/components/atoms/rating/rating.component.html
@az108
az108 requested a review from Cathy0123456789 July 26, 2026 20:11

@Cathy0123456789 Cathy0123456789 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for fixing that, another thing I realized. When tabbing to a picture in the Banner Image Selection and tabbing to the delete button, now a popup opens but the focus stays in the background, instead of switching to the popup, so I can never actually choose something in the popup by tabbing.
Same thing happens with the upload button component and deleting an uploaded file.
I think in general whenever we have a popup opening?

Also in general, now deleting an image in the research group collection for example is possible by tabbing, but the user cannot see that the focus is currently on the trash icon, it is not visible.

…nly buttons

Opening a confirmation left focus behind the mask, so the dialog could not
be reached by keyboard at all. PrimeNG focuses the first control of its own
header, content or footer, and a headless dialog renders none of those, so
there was nothing for it to focus. The dialog now moves focus to its first
control itself, and hands focus back to whatever opened it on close, unless
that has since been removed. Ours is the only headless dialog, so the rest
already behaved.

The delete button on an image card is invisible until the card is hovered.
Tabbing still reached it, landing on a control the reader cannot see, so it
now appears on focus as well.

Both classes the review asked about are dropped. The buttons sit in flex
containers that already stretch them, one through grow and one through the
default stretch of a column, so w-full was doing nothing, and btn-bare
already restores the inherited alignment that text-left was repeating.

Co-Authored-By: Claude <noreply@anthropic.com>
@az108

az108 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — the focus one was a real bug, and a good catch. Fixed in 1bb2ff9, along with the invisible trash icon. Inline replies on the two class questions and the list-navigation idea.

Focus staying behind the popup. Our confirmation dialog uses PrimeNG's headless template. PrimeNG moves focus by looking for the first control inside its own header, content or footer — and in headless mode it renders none of those, so there was nothing for it to focus and the reader stayed behind the mask.

To your "in general whenever we have a popup opening?" — I checked, and no: #headless appears exactly once in the app, in jhi-confirm-dialog. Every other popup (jhi-dialog, and the dynamic dialogs) renders into PrimeNG's normal content slot, so they already move focus. That's why it showed up on the banner image delete and the uploaded file delete, which are both confirmations.

The dialog now moves focus to its first control itself, and hands focus back to whatever opened it when it closes — guarded, since the trigger is often gone by then, e.g. the delete button of the row you just deleted.

The invisible trash icon. Not a missing focus ring: the button is opacity-0 until its card is hovered. opacity-0 still leaves an element focusable, so tabbing landed on a control that was genuinely not rendered visibly — a 2.4.7 failure. It now appears on focus as well as hover, in all three image grids (research group images, and both department image lists).

The w-full / text-left questions. Both redundant, both dropped — details in the inline replies.

The long professor list. Not changed yet; I've replied inline with what I think it should look like and one question about scope, since it touches the add-members dialog too.

Gates green: 2100 client tests, typecheck, ESLint, a11y lint, prettier, production build.

az108 and others added 2 commits July 28, 2026 16:32
Both lists put every person between the reader and the rest of the form,
which is a long way round when a search returns twenty-five professors.
Each list is now a single tab stop: the arrows move between people, Home
and End jump to the ends, Enter or Space picks one, and Tab carries on to
the next field.

The rows say what they are as well. The lists are listboxes and the rows
are options that report whether they are selected, so the choice is
announced rather than inferred from a highlight. The people list allows
several at once and says so.

Its checkbox stopped being a second tab stop on every row. The row is the
control now, and the box shows the state the row already reports, so it is
no longer reachable or announced separately.

Because an option can be reached without the pointer, both lists draw a
focus outline that the hover tint alone was not providing.

Co-Authored-By: Claude <noreply@anthropic.com>
…ctive-elements

# Conflicts:
#	src/main/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.ts
#	src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.ts

@Cathy0123456789 Cathy0123456789 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, tested locally and reviewed code, LGTM

#candidateOption
type="button"
role="option"
class="btn-bare flex items-center gap-3 p-3 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using outline-primary is I think a bit inconsistent, since we don't do it anywhere else, but it's okay

<div
#userOption
role="option"
class="flex items-center gap-3 p-3 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Comment on lines +12 to +20
/* For native <button> elements used purely as clickable containers (cards, list rows,
icon triggers). Only covers what Tailwind's preflight leaves behind: buttons come out
centred and, since v4, with a default cursor. Background, border, padding, margin, font
and colour are already reset there, so repeating them here only creates something that
can fight the utilities on the same element.

Kept in the components layer for that same reason. Unlayered rules outrank every layered
one whatever their specificity, so declaring this outside a layer would let it beat the
utilities instead. Components sits before utilities, so utilities win. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a bit long comment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client Pull requests that update TypeScript code. (Added Automatically!) ready for review tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove duplicated click and keyboard handlers for interactive containers

2 participants