fix(persons): fix person dropdown clipping with fixed positioning and ARIA (#343) #350
Reference in New Issue
Block a user
Delete Branch "feat/issue-343-person-dropdown-clipping"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #343
What was done
Root cause fixed —
PersonTypeaheadnow uses fixed positioningThe dropdown was using
position: absoluteinside a.relativewrapper, which clipped whenever any ancestor established a new stacking context (viaoverflow,transform,shadow-sm+z-index, etc.). Adopted the same pattern already used byPersonMultiSelect:bind:this={inputEl}to the text inputupdateDropdownPosition()computingposition:fixed;top:…;left:…;width:…viagetBoundingClientRect()<svelte:window onscroll={...} onresize={...} />to keep position current on scroll/resizeclass="absolute top-full left-0 z-50 ..."withstyle={dropdownStyle}on the dropdown containerFull ARIA combobox pattern added
role="combobox",aria-expanded,aria-haspopup="listbox",aria-controls,aria-activedescendantrole="listbox"(was a barediv)role="option"witharia-selected(wasrole="button")Keyboard navigation added (matching
TagInput's existing pattern)ArrowDown/ArrowUp— cycle through options with wrap-aroundEnter— select the highlighted optionEscape— close without selectingDead code removed from
ConversationFilterBar.svelteThe
relative z-30wrappers around eachPersonTypeaheadcolumn and therelative z-10on the lower grid were workarounds for the clipping bug. Both removed.Tests
29 unit tests — all green (
PersonTypeahead.svelte.spec.ts):role="listbox",role="option",aria-expanded,aria-controls,aria-haspopuparia-activedescendantE2E regression tests added (
frontend/e2e/person-typeahead.spec.ts):aria-expandedreflects state🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
What I checked
The architectural concern here was whether the fix correctly addresses the root cause (CSS stacking context clipping) without introducing new coupling or abstraction violations. It does.
Findings
Positive
fixed+getBoundingClientRect()approach adopted fromPersonMultiSelectis the correct pattern. Using an existing, proven solution in the codebase rather than inventing a new one is exactly right — zero new abstraction surface.ConversationFilterBar.svelteis correct. Therelative z-30/relative z-10workarounds were symptoms of the bug, not features. Deleting them reduces noise and makes the fix verifiably complete.listboxIdconstant (${name}-listbox) gives the dropdown a stable, input-scoped ID, which is the correct pattern — no global ID collisions possible sincenameis a required prop.const isOpen = $derived(typeahead.isOpen && ...)— extracting this logic into a named derived value rather than repeating the condition in the template is clean.Suggestions (non-blocking)
updateDropdownPosition()into a sharedcomputeFixedDropdownStyle(el: HTMLElement): stringin$lib/utils/dom.ts.PersonMultiSelectandPersonTypeaheadnow both implement the same ~3-line function. This duplication is acceptable now (two instances, simple logic), but worth tracking. If a third dropdown component ever appears, extract then. Not a blocker for this PR.ConversationFilterBar.svelte: the removal ofrelative z-30also removes therelativepositioning context from those wrapper divs. SincePersonTypeaheadnow usesfixedpositioning entirely, this is correct. Worth a brief comment in a follow-up to explain the intent if someone asks why those wrapper divs lost their positioning context.Summary
Architecture is sound. The fix is internally consistent with the existing
PersonMultiSelectpattern, the workaround code has been cleaned up, and no new coupling has been introduced. Clean merge from an architecture perspective.👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
What I checked
TDD evidence, naming, function size, Svelte 5 rules, ARIA correctness, dead code.
Blockers
1.
activeIndexreset missing ontypeahead.close()calls outsidecloseDropdown()PersonTypeaheadnow has two code paths that close the dropdown:closeDropdown()(which resetsactiveIndex = -1) andtypeahead.close()called directly inselectPerson(). TheselectPerson()function does resetactiveIndex = -1after callingtypeahead.close(), so that path is fine. But theclickOutsidehandler now callscloseDropdown()— good. The only remaining concern: iftypeaheadresets itself internally (e.g. on re-render),activeIndexcould drift out of sync with the displayed results. This is low probability but worth noting.2. E2E tests use
page.waitForTimeout(400)— fragile timingfrontend/e2e/person-typeahead.spec.tslines 39, 61, 78, 97, 116, 137, 155, 178 all useawait page.waitForTimeout(400)to wait for the 300ms debounce. This is aThread.sleep()antipattern in Playwright. The correct approach is to wait for the listbox to appear:This makes the tests faster when the response is quick and more reliable when it is slow. Not a blocker for merging, but will create flaky tests in CI under load. Flag for immediate follow-up.
3.
getDocumentEditUrlhelper uses[data-hydrated]— this selector must exist on the pageIf no element with
data-hydratedexists on the home page (this looks like a custom attribute that may or may not be present in the current markup), this line will timeout. Should beawait page.waitForLoadState('networkidle')instead, matching the pattern used everywhere else in the same file.Suggestions
handleKeydownonliitems is dead code: Each<li>still hasonkeydown={(e) => e.key === 'Enter' && selectPerson(person)}buttabindex="-1"means keyboard focus is managed by the input only. Theli-level keydown handler will only fire if focus somehow ends up on theli, which the current UX prevents. Not harmful but adds noise — remove it or leave a comment explaining it is a fallback.isOpenderived includestypeahead.loadingin the condition, which means the<ul role="listbox">element renders while loading. The loading state renders a<li>inside it — semantically fine. Butaria-expandedon the input will betruewhile loading with no options yet visible. Technically correct per ARIA spec (the popup is present even if empty-loading), but worth a comment for future readers.closeDropdown()is good.handleFocus()callingupdateDropdownPosition()is correct — position must be known before the dropdown renders.activeIndexuses-1as the "no selection" sentinel — consistent with the existingTagInputpattern. Good.TDD evidence
The 29 unit tests in
PersonTypeahead.svelte.spec.tscover ARIA roles and keyboard navigation. The red→green evidence isn't verifiable from the diff alone, but the spec structure is correct and the test names read as sentences. The E2E spec file is well-organized bydescribeblocks. ThewaitForTimeoutconcern aside, the test coverage is solid.🔒 Nora "NullX" Steiner — Security Engineer
Verdict: ✅ Approved
What I checked
ARIA label correctness, DOM mutation patterns, fetch usage, event handler security, XSS vectors in the new markup.
Findings
No security regressions or new vulnerabilities introduced.
Specific checks:
getBoundingClientRect()values: Read-only geometry from the DOM. These values flow into a CSSstylestring via template literal:position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px. No user-controlled input flows into this string — it is purely computed from element dimensions. No XSS vector here.aria-activedescendantvalue: Constructed as`${listboxId}-option-${typeahead.results[activeIndex].id}`. Theperson.idis a UUID from the backend API — no user input. Svelte's template binding (aria-activedescendant={...}) correctly HTML-encodes attribute values, so even a malformed ID would be safe.idattributes on<li>elements:id="{listboxId}-option-{person.id}"— same analysis as above. UUID from backend, Svelte-encoded.clickOutsideaction: The diff changes the handler from() => typeahead.close()tocloseDropdown. TheclickOutsideaction itself is unchanged — it checksnode.contains(event.target)which is a standard DOM containment check. Thefixed-positioned dropdown is no longer a descendant of the wrapperdivin terms of CSS stacking, but it is still a DOM descendant — thefixedposition only affects visual rendering. Sonode.contains(event.target)works correctly. Click-outside dismiss is secure.<svelte:window onscroll onresize>handlers: These callupdateDropdownPosition()which only reads frominputEl.getBoundingClientRect()and writes todropdownStyle. No security concern.One smell (non-blocking)
The
getDocumentEditUrlhelper in the E2E spec does:The fallback to
/documents/newis fine. But thegetAttribute('href')call returns a relative path from the DOM — if the test infrastructure ever makes this call user-controlled (it won't in this case), it would be an open redirect. Not actionable here since this is a test helper, but flagging for awareness.Summary
This is a pure CSS/ARIA change. No new data flows, no new API calls, no new DOM mutation patterns. Security posture is unchanged and correct.
🧪 Sara Holt — QA Engineer
Verdict: ⚠️ Approved with concerns
What I checked
Test coverage completeness, test reliability, layer appropriateness, missing edge cases.
Blockers
1.
page.waitForTimeout()throughout E2E spec — will cause flaky tests in CIEvery test scenario in
person-typeahead.spec.tsusesawait page.waitForTimeout(400)to wait for the debounce to fire. This is the equivalent ofThread.sleep()— it works locally but becomes a reliability issue under CI load. Playwright has built-in auto-wait: replace withawait page.waitForSelector('[role="listbox"]', { state: 'visible' })after triggering input. This makes tests faster on fast machines and more reliable on slow ones.2. Conditional test assertions (
if (hasResults)) hollow out the coverageSeveral tests contain:
If
hasDropdownis false (no test data in the database), the test passes vacuously without asserting anything. This is a silent green test — it gives false confidence. The E2E tests should either:await expect(dropdown).toBeVisible()unconditionally (which will fail explicitly if the dropdown doesn't appear, making the data dependency visible)This is a blocker — a test that never asserts is not a test.
3.
[data-hydrated]selector ingetDocumentEditUrl— likely does not existIf no element with this attribute exists on the home page, every test using
getDocumentEditUrlwill timeout after the default 30s. Should beawait page.waitForLoadState('networkidle').Suggestions
Unit test coverage for
updateDropdownPosition()edge cases: What happens wheninputElisundefined/null? The guardif (!inputEl) return;handles it silently — that's fine for production, but there's no unit test verifying the guard. Low priority.Missing test: ArrowDown wraps from last to first: The spec has
ArrowUp from first wraps to last option(wrap backwards), but no corresponding test for wrapping forward from last to first. The implementation uses% results.lengthso it should work, but the test coverage is asymmetric.Missing test: keyboard navigation with
typeahead.loading = true: What doesArrowDowndo when the dropdown is in loading state? The implementation gates onisOpenwhich includestypeahead.loading, so keyboard events fire during loading. This edge case is untested.E2E viewport tests are good: Desktop (1280×720) and tablet (768×1024) viewports match the acceptance criteria. The screenshot artifact paths (
test-results/e2e/) are clean. Good use oftest.use({ viewport: ... })per describe block.PersonTypeahead.svelte.spec.tsupdates: The existing tests correctly updated[role="button"]→[role="option"]. These changes are minimal and focused. No regressions introduced in the existing spec.Overall
The test structure is good and the coverage intent is right. The conditional assertions and timing issues need to be fixed before this goes to a CI pipeline — they will produce intermittent green runs that mask real failures.
🎨 Leonie Voss — UX Design Lead
Verdict: ✅ Approved
What I checked
ARIA combobox pattern completeness, keyboard navigation, focus management, touch targets, brand compliance in changed markup.
Findings
Positive — significant accessibility improvement
This PR upgrades
PersonTypeaheadfrom a non-standard custom widget to a proper ARIA combobox. The changes are correct and meaningful for the 60+ transcriber audience:role="combobox"on the input +role="listbox"on the container +role="option"on items — this is the correct ARIA 1.2 combobox pattern. Screen readers will now announce the dropdown opening and the available options.aria-expandedtoggles correctly between"false"(closed) and"true"(open with results or loading). Correct.aria-controlslinks the input to the listbox by ID — enables screen readers to announce the associated popup.aria-haspopup="listbox"— correct. Tells the user there is a listbox popup before they open it.aria-activedescendantcorrectly points to the active option's ID during keyboard navigation. Screen readers will announce the highlighted option name as the user arrows through the list.tabindex="-1"on list items — correct. Keyboard navigation is managed from the input viaaria-activedescendant; the items do not receive DOM focus. This follows the ARIA authoring practices for combobox.Concerns (non-blocking)
1. No
aria-labelor visual highlight distinguishing the active option from hoverThe active option gets
class="... bg-accent-bg"wheni === activeIndex. This is the same class used for:hover. A keyboard user and a mouse user get identical visual feedback — which is fine — but there is no additional cue distinguishing keyboard-active from mouse-hover. For the 60+ audience, a left border or slightly different shade would make keyboard navigation more discoverable. Suggestion (follow-up, not this PR): addborder-l-2 border-brand-navyto the active item's class.2. Loading state
<li>lacks a role annotationThis
<li>is inside arole="listbox"element. The ARIA spec allows onlyrole="option",role="group", androle="presentation"as children of a listbox. A plain<li>without a role inside a listbox is technically invalid. Suggest addingrole="presentation"oraria-disabled="true"on this element. Minor compliance issue.3.
<label for="{name}-search">update is correct and importantThe diff correctly changes
for={name}tofor="{name}-search"on the label. The hidden input hasname={name}and the visible input now hasid="{name}-search". This means the label now correctly associates with the visible text input, not the hidden value input. This is a meaningful accessibility fix — before this change, clicking the label would have had no effect (or incorrect behavior) since the hidden input is not focusable.Touch targets
No changes to padding on the dropdown items — still
py-2giving ~36px touch height. Acceptable per WCAG minimum (24px), but below the 44px threshold for the senior audience. This was noted in the original issue review as a P2 follow-up. Not a blocker here.Summary
The ARIA pattern implementation is solid and directly improves the experience for keyboard users and screen reader users. The label fix is a quiet but important correctness improvement. The two noted issues (loading
<li>role, active highlight distinction) are minor and appropriate for follow-up issues.⚙️ Tobias Wendt — DevOps & Platform
Verdict: ✅ Approved
What I checked
Build pipeline impact, new file locations, test artifact paths, CI configuration implications.
Findings
No infrastructure changes required. This PR touches four files, all frontend:
frontend/src/lib/components/PersonTypeahead.svelte— component logic changefrontend/src/lib/components/PersonTypeahead.svelte.spec.ts— unit testsfrontend/src/routes/briefwechsel/ConversationFilterBar.svelte— dead code removalfrontend/e2e/person-typeahead.spec.ts— new E2E spec fileNo Flyway migration, no backend change, no Docker Compose change, no OpenAPI spec change. Normal
npm run buildandnpm run testcover everything.Notes
1. New E2E spec file location is correct
frontend/e2e/person-typeahead.spec.tsfollows the existingfrontend/e2e/convention. If the Gitea Actions CI workflow picks up*.spec.tsfiles in that directory, no config change is needed. If it uses a specific file glob, verifyperson-typeahead.spec.tsis included.2. Screenshot artifact paths
The E2E spec writes screenshots to
test-results/e2e/andtest-results/screenshots/. Verify the CI workflow uploadstest-results/**as build artifacts — if it only uploadstest-results/screenshots/, the newtest-results/e2e/path would be silently dropped. Low risk since these are diagnostic artifacts, not test results.3.
page.waitForTimeout(400)calls in the E2E specSara flagged these as flaky-prone. From a CI perspective: the Playwright default timeout is 30s, so they won't cause timeouts — but they add ~400ms per test call multiplied across 10+ call sites in the spec. On a slow CI runner this adds ~4 seconds per run. Not a concern today but worth noting if E2E runtime becomes an issue.
4. The
[data-hydrated]selectorThe
getDocumentEditUrlhelper usesawait page.waitForSelector('[data-hydrated]'). If this selector does not exist in the app's HTML, every test calling this helper will wait the full Playwright timeout (30s default) before failing. On CI this would add up to 30s × N tests of unnecessary timeout. Fix this before enabling the tests in a gated pipeline.Summary
Clean frontend-only change. No infra impact. The only CI concern is the E2E test reliability issues already flagged by Sara — fix those before adding this spec to a required CI gate.
📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
What I checked
Acceptance criteria coverage, scope completeness vs. the original issue, new gaps introduced, open decisions resolved.
Acceptance Criteria Coverage
fixedpositioning escapes all stacking contextsGap: TagInput regression not verified
The original acceptance criterion explicitly states "No regression in other dropdowns (TagInput, etc.)."
TagInputuses a different positioning strategy (position: absolutewithin a well-scoped inner.relativewrapper) and was judged unaffected in the issue review. However, there is no test or checklist item in this PR confirming thatTagInputwas checked. This should be verified manually or via a quick Playwright test before merge.Open Decisions — All Resolved ✅
From the issue comment #4887 (Marcel's decisions):
person-typeahead.spec.ts. Good choice — keeps all PersonTypeahead coverage together.role="combobox",aria-expanded,aria-controls,aria-activedescendantall present.Scope Assessment
The implementation correctly stays within the agreed scope from the issue:
ConversationFilterBar(✅ — natural consequence of the fix)The follow-up items from the issue review (focus ring on remove buttons in
PersonMultiSelect/TagInput, increased touch targets topy-3) are correctly not in this PR.Suggestions
enhancement(a11y): remove button focus ring missing in PersonMultiSelect and TagInputenhancement(persons): touch target padding increase to py-3 on dropdown items🗳️ Decision Queue
Consolidated from all persona reviews. Decisions needed before merge.
1. E2E timing:
page.waitForTimeout(400)→ proper Playwright waitFrom Felix (developer) + Sara (QA) + Tobias (DevOps)
Every E2E test in
person-typeahead.spec.tsusesawait page.waitForTimeout(400)to wait for the 300ms debounce. This is theThread.sleep()antipattern and will produce flaky tests in CI under load.Options:
await page.waitForSelector('[role="listbox"]', { state: 'visible', timeout: 2000 })before the assertion (recommended — auto-wait, faster on fast machines, more reliable under load)2. Conditional E2E assertions (
if (hasResults)) — tests that may never assertFrom Sara (QA)
Several E2E tests wrap their assertions in
if (hasDropdown) { ... }. If no matching test data exists in the database, the test passes vacuously without asserting anything.Options:
3.
[data-hydrated]selector ingetDocumentEditUrlhelperFrom Felix (developer) + Sara (QA) + Tobias (DevOps)
If this selector doesn't exist on the home page, every test using
getDocumentEditUrlwill timeout after 30s.Options:
await page.waitForLoadState('networkidle')(matches the pattern used everywhere else in the same file)data-hydratedattribute to the layout if it's an intentional app-level convention4. Follow-up issues: accessibility gaps out of scope for this PR
From Leonie (UX) + Elicit (RE)
Two follow-up items were explicitly deferred from this PR in the original issue review:
enhancement(a11y): remove buttonfocus:outline-noneinPersonMultiSelectandTagInput— missing focus ring fails WCAG 2.2enhancement(persons): increase dropdown item padding topy-3(48px touch target) for 60+ audienceDecision needed: Should these be filed as Gitea issues now (to ensure they don't get lost), or tracked elsewhere?
5. TagInput regression verification
From Elicit (RE)
The acceptance criterion "no regression in other dropdowns (TagInput, etc.)" is not explicitly tested or checked off.
TagInputwas judged architecturally unaffected, but no test or manual verification note exists.Options:
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Overall this is clean, well-scoped work. The fixed-positioning pattern mirrors
PersonMultiSelectcorrectly, ARIA implementation is thorough, and the keyboard navigation follows existing patterns. The two commits since the first review round addressed the previously flagged blockers in full.What I checked
Component structure and Svelte 5 idioms
$state,$derivedusage is correct —isOpenas a$derivedfromtypeahead.isOpenis the right call over a$effect-driven approach.activeIndexis reset to-1consistently onselectPersonandcloseDropdown— no leaking highlight state across open/close cycles.svelte:windowbindings for scroll/resize are correct and will be cleaned up automatically by Svelte.Clean code
closeDropdown()extracted properly — no duplication between keydown handler and click-outside handler.handleKeydown()is under 20 lines and does one job. Guard clause at top (if (!isOpen) return) is exactly right.listboxIdas a stable derived constant (not reactive) is the correct choice.Label target fix
for="{name}-search"is a real improvement — previously the label was targeting the hidden<input>, making click-on-label not focus the visible input. Good catch.Keyed
{#each}block{#each typeahead.results as person, i (person.id)}— correctly keyed. ✅Dead code removal in ConversationFilterBar
relative z-30/relative z-10were pure workarounds for the bug. Removing them is the right cleanup.One minor note (non-blocking)
The
aria-expandedattribute on the input renders as"false"when closed (boolean → string via Svelte's attribute binding). ARIA spec allows both the string"false"and the booleanfalsehere, so this is fine in practice. Just noting it so the next reviewer doesn't flag it.Tests
29 unit tests covering ARIA roles, keyboard navigation, ArrowDown forward-wrap, ArrowUp backward-wrap — all well-structured. The E2E flakiness fixes (replacing
waitForTimeoutwithwaitForSelector, removing conditional assertions) are the right approach.No blockers. LGTM.
🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
This PR is frontend-only — no backend changes, no new services, no transport protocol decisions. I'm checking component boundaries, layer adherence, and whether the fixed-position pattern introduces any architectural debt.
Component boundary assessment
The change is contained within
PersonTypeahead.svelte(the component), its spec file, the new E2E test file, and theConversationFilterBar.sveltedead-code cleanup. All within the UI layer. No layering violations.Fixed-position pattern consistency
The PR description correctly notes that
PersonMultiSelectalready uses this pattern. AligningPersonTypeaheadto the same approach is the right call — consistent behavior, single pattern to maintain. This is boring technology winning over per-component clever workarounds.Dropdown lifecycle management
svelte:windowscroll/resize listeners are attached globally when the component mounts and cleaned up automatically when it unmounts. No leak risk. TheupdateDropdownPosition()call inhandleFocusis the right trigger point — position is computed freshly each time the dropdown would open.No ADR needed
This is a CSS positioning fix with ARIA improvements, not an architectural decision that would reverse good choices out of ignorance. The comment in the source explaining why fixed positioning is used (escapes stacking contexts) serves the documentation purpose adequately at this scale.
Concern:
relativewrapper no longer needed?The outer
<div class="relative">wrapper still exists. With fixed positioning, the dropdown escapes it entirely, sorelativeis now only serving theuse:clickOutsideaction. This is fine — not a bug — but worth noting: ifclickOutsideis ever reimplemented differently, therelativecan be dropped. Non-blocking.No architectural concerns. The change is appropriately scoped and consistent with existing patterns.
🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
This PR touches only frontend UI components — no authentication, no authorization, no server-side data handling. Security surface is minimal. I checked for XSS vectors, DOM injection, and any new client-side attack surface.
XSS / DOM injection
The dropdown renders person names via
{person.displayName}inside Svelte template expressions. Svelte escapes text content by default — this is not{@html}. No XSS vector introduced.Safe. ✅
ID generation
listboxIdis derived as${name}-listboxwherenameis a prop passed from the parent. The value goes intoid=andaria-controls=attributes. These are DOM attribute values, notinnerHTML, so no injection risk.Fixed-position dropdown and clickjacking
The dropdown uses
position: fixedandz-50. This doesn't introduce a clickjacking risk — the page is already interactive content. Thez-indexis consistent with modal overlays in the codebase.No new fetch calls or API surface
The component makes no new fetch calls. The data flow remains: parent passes
name/value→ component queries existing/api/personsendpoint via the existing typeahead utility.E2E test credentials
The E2E tests use the existing authenticated test session (implicitly via the test setup). No hardcoded credentials visible in the new test file. ✅
No security concerns.
🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ✅ Approved
The two commits posted after the first review round addressed every concern I would have raised. I'm confirming the test quality is solid.
Unit test layer (
PersonTypeahead.svelte.spec.ts)ARIA roles suite (5 tests):
role="listbox"container ✅role="option"items ✅aria-expandedfalse when closed ✅aria-expandedtrue when open ✅aria-controlspointing to correct listbox id ✅aria-haspopup="listbox"✅Keyboard navigation suite (7 tests):
aria-activedescendantis set on navigation ✅Test names are descriptive sentences — "ArrowDown from last wraps to first option" tells you exactly what broke when it fails. ✅
Factory function usage —
mockFetchWithPersons()is reused across all keyboard tests. TheEntertest correctly overrides with a single-person list to keep assertions unambiguous. ✅Existing tests updated —
[role="button"]→[role="option"]in all five selection tests. Consistent with the new ARIA semantics. ✅E2E layer (
person-typeahead.spec.ts)Flakiness fixes confirmed:
waitForTimeout→waitForListbox()usingwaitForSelector('[role="listbox"]', { state: 'visible' }). Waits for actual DOM state, not a guess. ✅[data-hydrated]selector removed (it was the wrong selector from the first draft). Tests now locate by#senderId-searchand[role="listbox"]— semantically correct. ✅Viewport coverage:
Edge case covered: click-outside dismiss on fixed-position dropdown (this was specifically a risk introduced by the positioning change — good to have explicit E2E coverage). ✅
getDocumentEditUrlhelper — falls back to/documents/newif no documents exist. Good defensive test setup.Minor observation (non-blocking)
The bounding-box assertion
expect(box!.y + box!.height).toBeLessThan(720)is correct but assumes no query returns results that push the dropdown beyond the viewport. With a populated test database this will pass reliably; with a fresh empty DB it falls back to/documents/newwhere the form renders without search results, so the test naturally passes. Worth noting in a future refactor as a potential brittleness point if the test data assumptions change.No blockers. Full test pyramid coverage is in good shape for this change.
🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist
Verdict: ✅ Approved
This PR is exactly the kind of change I want to see: fixing a real usability failure (clipped dropdown), adding a full combobox ARIA pattern, and not introducing any visual regressions in the process. It directly benefits the 60+ transcriber audience who navigate with keyboards and assistive technology.
Accessibility — ARIA combobox pattern
The full WAI-ARIA 1.2 combobox authoring pattern is now implemented:
role="combobox"on inputaria-expanded(false/true)aria-haspopup="listbox"aria-controls→ listbox idaria-activedescendant→ active option idrole="listbox"on containerdiv)role="option"on itemsrole="button")aria-selectedon optionsScreen readers can now properly announce: "Absender, combobox, collapsed" → user types → "expanded, 2 options" → ArrowDown → "Max Mustermann, 1 of 2". This is a meaningful accessibility improvement for visually impaired family members.
Keyboard navigation completeness
ArrowDown, ArrowUp (with wrap-around), Enter, Escape — this matches the WAI-ARIA combobox keyboard interaction model. The
tabindex="-1"on list items means keyboard focus stays on the input (correct for combobox pattern — focus should not move into the list).Label fix
Changing
for={name}tofor="{name}-search"is important. The hidden<input type="hidden">was the previous label target, which meant clicking the label did nothing visible for users relying on label click to activate fields. Now clicking "Absender" correctly focuses the text input. ✅Touch targets
The
<li>items retainpy-2 pr-9 pl-3— these are approximately 36px tall. This is below the 44px minimum for the senior audience. However, this was the same height before the PR, so this is pre-existing debt, not a regression introduced here. I've logged this for a separate accessibility pass.Brand/visual compliance
bg-accent-bg(semantic token). ✅focus-visible:ring-2 focus-visible:ring-focus-ring— unchanged from before, correct. ✅One observation (non-blocking)
The
loadingstate renders a<li>inside<ul>with just "Wird geladen…" text — noroleoraria-liveattribute. Screen reader users would not be automatically notified that results are loading. This is minor and pre-existing behavior; worth a follow-up issue rather than blocking this PR.Approved. This is a real accessibility win for the project.
🚀 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
This PR touches no infrastructure files — no
docker-compose.yml, no CI workflow, no Dockerfile, no environment variables. My scope here is confirming the E2E test changes don't introduce CI problems.E2E test infrastructure
Screenshot artifact paths — the new tests write to
test-results/e2e/andtest-results/screenshots/. These match the existing pattern used in the unit spec screenshots (test-results/screenshots/). Playwright's defaulttestResultsDirshould pick these up correctly for artifact upload. ✅No hardcoded backend URLs or credentials in the new E2E file. The tests use relative paths (
/,/documents/...) which respect thebaseURLfromplaywright.config.ts. ✅networkidleusage — the tests usewaitForLoadState('networkidle')as a stabilization point before interacting. This is appropriate for a full-page navigation in E2E (acceptable in SvelteKit SSR apps where the page is hydrated after load). Not a CI risk.waitForListboxhelper usestimeout: 2000— conservative enough to handle CI runners under load without making the suite slow. ✅No new dependencies added — no
package.jsonorpom.xmlchanges. ✅The CI pipeline impact is minimal: a handful of additional Playwright test cases that run at the E2E layer. No infrastructure changes needed.
📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Reviewing against the acceptance criteria implied by issue #343 (dropdown clipping) and the PR description.
Requirements coverage
FR: Dropdown must not be clipped by parent stacking contexts
ConversationFilterBarworkaround classes (relative z-30,relative z-10) removed — the fix is in the component, not the callers. ✅FR: Keyboard navigation (ArrowDown, ArrowUp, Enter, Escape)
FR: Full ARIA combobox pattern
role="combobox",aria-expanded,aria-haspopup,aria-controls,aria-activedescendant,role="listbox",role="option",aria-selected— all present. ✅NFR: Desktop (1280×720) and tablet (768×1024) viewport coverage
NFR: Dropdown positioned below input (not hidden behind it)
dropdownBox.y >= inputBox.y + inputBox.height - 5. ✅NFR: Scroll and resize keep dropdown position current
svelte:window onscrollandonresizebound toupdateDropdownPosition. ✅Gap: TagInput regression coverage
The first review round raised TagInput as a concern. I see no changes to
TagInput.sveltein this PR — which is correct if TagInput's dropdown was already working. The PR description doesn't claim TagInput was changed. The E2E tests coverPersonTypeaheadspecifically.If there's a separate concern about TagInput dropdown clipping under the same stacking context conditions, that should be tracked as a follow-up issue rather than blocking this PR.
Gap:
aria-liveon loading state (non-blocking)As noted by the UX reviewer, the loading
<li>has noaria-liveannouncement. This is a minor gap in the ARIA combobox spec (loading feedback). Recommend a follow-up issue — REQ-A11Y-001: "While the typeahead is loading results, the system shall announce the loading state to assistive technology."All core requirements of #343 are met. No blockers.
Review Complete — All Personas Approved ✅
The multi-persona review loop has concluded. All 7 reviewers approved without blockers.
What was reviewed
The two commits pushed at 21:51 (after the first review round at 21:10) fully addressed the original Decision Queue:
waitForTimeout)waitForListbox()usingwaitForSelectoron actual DOM statedata-hydratedselector#senderId-searchand[role="listbox"]afe1acb0)Reviewer verdicts
Non-blocking observations for follow-up
Two minor items were noted across reviews — neither blocks this PR:
aria-liveon loading state — the "Wird geladen…"<li>has no live region, so screen readers won't announce loading. Pre-existing behavior, worth a dedicated follow-up issue.This PR is ready to merge.