Appearance
[UX] customer-portal — Skills & employees
Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch:
develop@693a76c· Files reviewed: 9 Patterns: data-display/table (get_pattern "Data Table" consulted; anti-pattern body too large to page through — cited from baseline)
Summary
The list surface (SkillsPage, @aadalen/ui DataTable) is keyboard-accessible on desktop, but the employee-detail surface is not: the skill and training lists are hand-rolled <li @click> rows with no role, tabindex or key handler, so a keyboard user cannot open a record to edit it. Across every add/edit dialog the field labels are unassociated <label> siblings, and several submit buttons disable themselves while the form is invalid or submitting. i18n parity holds (en/nb, 101 keys each, Norwegian genuinely translated) and destructive actions confirm and say what is lost.
Findings
1. Skill and training rows are mouse-only — no keyboard path to edit — High · A11Y-03
Where: apps/web/src/components/employee/EmployeeSkillsSection.vue:300-310; apps/web/src/components/employee/EmployeeTrainingsSection.vue:154-159What: Each record is a <li class="cursor-pointer" @click="openEditSkill(s)"> (and @click="openEditTraining(tr)") with no role, tabindex or @keydown. Opening a record — the primary action that reveals the edit dialog for validity dates, attachment, instructor, etc. — is reachable only with a pointer. The delete <Button> and the attachment <a> inside the row are real controls and stay reachable, so a keyboard user can delete or download a record but cannot edit one. Why it matters: Keyboard-only and switch/AT users are locked out of the core editing function on the employee detail page at every viewport (WCAG 2.1.1). Contrast the sibling DataTable, which correctly gives its clickable rows role="button", tabindex="0" and @keydown.enter (packages/ui/src/components/DataTable.vue:611-616). Fix: Make each row a real button: add role="button", tabindex="0" and @keydown.enter/space calling the same handler, or wrap the row body in a <button>. Keep the nested delete/download controls with @click.stop as they already are.
2. Dialog field labels are not programmatically associated — Medium · FORM-01
Where: apps/web/src/pages/SkillsPage.vue:446,453,460,494,519,582; apps/web/src/components/employee/EmployeeSkillsSection.vue:363,378,387,397,448,455,463,474; apps/web/src/components/employee/EmployeeTrainingsSection.vue:190,200,204,208,228,234,237,241What: Every dialog field uses a bare <label class="text-sm font-medium">…</label> placed as a sibling before the InputText/AutoComplete/Select/DatePicker, with no for/id pairing and without wrapping the control. The label is visual only. Why it matters: Screen-reader users get no accessible name when focus lands on the field (the "Name", "Valid from", "Instructor", "New skill" inputs all read as unlabeled), and clicking the label does not focus the control. This repeats across all six dialogs (add employee, standards, add/edit skill, add/edit training). Fix: Give each control an id/inputId and point the label's for at it, or wrap the control inside the <label>. PrimeVue inputs accept inputId/labelledby for this.
3. Submit buttons disable while invalid and during submission — Medium · FORM-05
Where: apps/web/src/pages/SkillsPage.vue:475 (add-employee Save), :586 (add skill definition), :541 (rename skill definition) What: These buttons carry :disabled="!field.trim() || mutation.isPending.value". The submit is dead until the required text is typed, and dead again while the request is in flight. Notably, the parallel add-skill / add-training dialogs do the opposite — they keep Save enabled and surface a requiredFields toast on empty submit (EmployeeSkillsSection.vue:122-124, EmployeeTrainingsSection.vue:47-49), so the feature is internally inconsistent. Why it matters: A disabled submit gives keyboard and screen-reader users nothing to activate and no message explaining the block (FORM-05). Re-disabling on isPending also drops focus to <body> when the button deactivates mid-submit (FORM-06). The inconsistency means two ways to fill "the same" form behave differently. Fix: Keep submits enabled; validate in the handler and show the existing requiredFields/inline message on invalid submit, matching the skill/training dialogs. Use aria-busy rather than disabled for the in-flight state.
4. Employee cards on mobile are click-only — Low · A11Y-03
Where: apps/web/src/pages/SkillsPage.vue:375-378What: At ≤760px DataTable renders the #card slot, here a PrimeVue <Card class="cursor-pointer" @click="handleRowClick(row)"> with no role/tabindex/@keydown. Desktop (the DataTable table branch) is keyboard-accessible, so this only bites an external keyboard at a narrow viewport. Why it matters: On a keyboard at mobile width, employees cannot be opened. Lower severity than #1 because the desktop table path is fine and the population is small. Fix: Add role="button", tabindex="0" and an Enter/Space handler to the card, or render the card body as a <button>/<router-link>.
5. Raw backend error strings reach users on skill-standard failures — Medium · MSG-02
Where: apps/web/src/pages/SkillsPage.vue:101,134 (toast detail: error.message); source at apps/web/src/domains/skills/skills.api.ts:26-38What: skills.api.ts builds ApiError.message from the backend's body.message (string or joined array). saveEditSkillDefinition and confirmDeleteSkillDefinition pass that raw string straight into the toast detail. So a rename/delete failure shows the untranslated server sentence (e.g. a validation or FK-constraint message) rather than one mapped human line. Why it matters: nb users see English/technical prose; the copy is unpredictable and can leak internals. This is the feature-local instance of the project-wide MSG-02 theme, but via skills.api.ts rather than the shared getErrorMessage helper. Fix: Map known status codes (e.g. 409 → "This skill is in use and can't be removed") to localised strings; fall back to one generic t() sentence. Don't interpolate error.message.
Project-level defects that also affect this feature (referenced, not re-filed)
- NAV-03 — one static document title app-wide; fails project-wide, see PROJECT-LEVEL.md.
- DataTable error state —
SkillsPagepasses:is-errorbut noerrorMessage/status(SkillsPage.vue:347), so a failed employee load renders the sharedDataTable's hardcoded English "Failed to load data." with no retry and no localisation. This is the sharedpackages/uiDataTablecluster; seedrafts/customer-portal--catalog.mdfindings 4-7. Not re-derived here.
Unverified
- A11Y-01 (contrast) — status pills (
text-status-*-fgonbg-status-*-bg), mutedtext-text-3"—", and the amber banner need a contrast tool on rendered colours. Not assessable from class names. - A11Y-06 (responsive / short viewport) — dialogs use
ResponsiveDialog(BottomSheet ≤760px) with:breakpoints, but behaviour at ≈700px height with a mobile keyboard open needs a rendered viewport. - FORM-06 focus drop on the
EmployeeFormDialogSave (:loading="saving"only) depends on whether PrimeVueButton's:loadingsets nativedisabled.node_modulesis not installed, so PrimeVue internals (Button loading→disabled,Dialog/Select/DatePicker/AutoCompletefocus-trap, keyboard and label wiring,Toastrole="alert"for MSG-01) cannot be read from source and are left Unverified.
Baseline additions
- A11Y-ROW-SEMANTICS (proposed) — a row/card that acts as a link or button must be a real interactive element (native control or
role="button"/role="link"+tabindex="0"+ Enter/Space handler); a bare<li @click>/<div @click>is mouse-only. (This feature shows it on hand-rolled lists; the sharedDataTablealready complies. Likely folds into the existing project-wide A11Y-03 "click-only rows/chips" theme — orchestrator to reconcile.)
Cross-project note
The click-only <li>/<div @click> row pattern (#1, #4) is the customer-portal instance of the A11Y-03 theme already confirmed in playout, members (TableData.vue mouse-only row actions) and tt-time-tracker — see PROJECT-LEVEL.md alignment table. Unassociated dialog labels (#2) and disabled-while-invalid submits (#3) are worth spot-checking in the other three admin/CRUD surfaces, as they are hand-rolled here rather than inherited from a shared shell.