Development: Use real buttons and links instead of click-handler divs - #2529
Development: Use real buttons and links instead of click-handler divs#2529az108 wants to merge 17 commits into
Development: Use real buttons and links instead of click-handler divs#2529Conversation
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>
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| Security | 3 high |
🟢 Metrics 49 complexity
Metric Results Complexity 49
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.
|
📊 Client Test Coverage Too Low 🔍 View coverage locally: npm run test:ci
open build/test-results/vitest/coverage/index.html🌐 View coverage from GitHub: |
…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
…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
|
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. |
|
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. |
|
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. |
|
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. |
|
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. |
|
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. |
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>
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>
…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>
…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>
…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>
|
Thanks for testing this properly — all four are addressed in 0644566, and I've replied inline on each. The shrunken upload tile. I checked the other ~14 div-to-button conversions in this PR for the same thing — the rest all carry an explicit One more you didn't flag: the same conversion replaced the translated aria-label on Summary of the four:
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
Cathy0123456789
left a comment
There was a problem hiding this comment.
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>
|
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: 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 The 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. |
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
left a comment
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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" |
| /* 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. */ |
There was a problem hiding this comment.
Maybe a bit long comment





Checklist
General
Client
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 withouthrefhad 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-bareclass inglobal.scssthat strips the button chrome so the existing card and row styling survives. Affected:sidebar-button,image-upload-button,upload-button,ratingsegments,string-inputhelper-text link,selectable-slot-card,interview-process-card,document-dialogrows, theprofile-picture-settingsavatar trigger, and thelogin/registrationswitch-view links, which were<a>withouthrefand so never links at all.Link-like (4) — now
<a [routerLink]>. Affected: the footer's imprint, privacy and about-us links, whose navigation methods onFooterComponentare gone, andjob-card, where anonViewDetails/onKeyDownpair 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 injob-creation-form.click-events-have-key-eventswas givenignoreWithDirectives: ['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:
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 arole="presentation"delegator again, and no longer takes a tab stop of its own.opacity-0still leaves an element focusable, so tabbing landed on a control nobody could see. It now appears on focus as well..btn-bareis down to the two declarations Tailwind does not already reset, inside@layer componentsso utilities still win.job-cardno 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
Keyboard
Review Progress
Code Review
Manual Tests
Test Coverage
Client
Last updated: 2026-07-28 14:53:04 UTC