Skip to content

feat(frontend): chip-select component + global filter chips in topbar (#344 T1+T2) - #345

Merged
cristim merged 4 commits into
feat/multicloud-web-frontendfrom
feat/issue-344-ui-revamp-phase2
May 13, 2026
Merged

feat(frontend): chip-select component + global filter chips in topbar (#344 T1+T2)#345
cristim merged 4 commits into
feat/multicloud-web-frontendfrom
feat/issue-344-ui-revamp-phase2

Conversation

@cristim

@cristim cristim commented May 12, 2026

Copy link
Copy Markdown
Member

Summary

Issue #344 (UI revamp phase 2) — this PR bundles tasks T1 + T2 of the original 4-task plan:

  • T1: ship the chip-select component (custom popover listbox).
  • T2: relocate the Provider + Account filters from the per-section <select> dropdowns into a single pair of chip-select chips in the global topbar.

T3 (loading skeletons) and T4 (Plan-builder drawer) land in follow-up PRs against this branch.

T1 — chip-select component

frontend/src/lib/chip-select.ts — pill trigger + custom popover listbox. API:

const { root, setValue, getValue, setOptions } = createChipSelect({
  label: 'Provider',
  options: [{ value: '', label: 'All' }, { value: 'aws', label: 'AWS' }, ],
  value: '',
  onChange: (newValue) => ,
});

Behaviour: click-toggle / keyboard open (Enter / Space / ArrowDown), Esc + click-outside both close, arrow-key option navigation, in-popover search above 8 options, menu flips upward near viewport bottom, full ARIA (aria-haspopup="listbox", aria-expanded, role="listbox" + role="option" + aria-selected), DOM via createElement + textContent only (no innerHTML).

T2 — global filter chips in topbar

frontend/src/topbar-filters.ts mounts two chip-selects (Provider + Account) into a new #topbar-filters slot in the topbar. Each section subscribes to filter changes via state.subscribeProvider / state.subscribeAccount and reloads itself — no more three near-identical copies of the same filter on three different tabs.

The provider/account <select> dropdowns are deleted from #dashboard-controls, #plans-header, and #history-controls in index.html. dashboard.ts, plans.ts, and history.ts lose their DOM-binding / populateAccountFilter calls and gain a single state.subscribe* registration each. Issue #185's ordering invariant (clear state.currentAccountIDs BEFORE awaiting the account-list refetch on provider change) moves from dashboard.ts to topbar-filters.ts so it stays at the mutation site — regression test moves with it (__tests__/topbar-filters.test.ts).

Verification

Not in this PR

  • T3 (loading skeletons across data fetchers)
  • T4 (Plan-builder drawer on Opportunities)

Both will be stacked on top of this branch in follow-up PRs.

Summary by CodeRabbit

  • New Features

    • Moved provider and account filters to a global topbar location, making them accessible across all tabs for consistent filtering.
    • Introduced a new chip-select filter component with search functionality for easier provider and account selection.
  • Tests

    • Added comprehensive test coverage for the new chip-select component and topbar filter integration.
    • Updated existing tests to align with the refactored filter architecture.

Review Change Stack

…ssue #344 T1)

First task of UI revamp phase 2 (#344). New `lib/chip-select.ts` component
replaces native `<select>` for filter use-cases where we want a compact
pill trigger that opens a custom listbox.

Component shape:
  createChipSelect({ label, options, value, onChange, searchThreshold? })
    → { root, setValue, getValue, setOptions }

Features:
- Pill-shaped trigger that mirrors the existing `.filter-group select`
  visual (so adopting it in T2 is a drop-in replacement).
- Click-toggle + keyboard open (Enter / Space / ArrowDown on trigger).
- Listbox menu opens below; flips upward when near viewport bottom.
- Escape + click-outside both close the menu.
- Arrow-key navigation through options; Enter selects the active one.
- In-popover search input renders automatically when options.length > 8
  (configurable via `searchThreshold`). Filters case-insensitively on
  label substring. "No matches" hint when filter excludes everything.
- ARIA: trigger has `aria-haspopup="listbox"` + `aria-expanded`;
  menu has `role="listbox"`; options have `role="option"` + `aria-selected`.
- All DOM construction via createElement + textContent (no innerHTML),
  matching the issue #340 XSS posture.

NOT touched in this commit (per the plan's T1 → T2 ordering):
- Existing `.filter-group select` callers in dashboard.ts / plans.ts /
  history.ts / recommendations.ts. Those swap over in T2.
- Top-bar filter relocation. That's T2.

Files:
- frontend/src/lib/chip-select.ts (NEW, ~270 lines)
- frontend/src/styles/components.css — appended ~110 lines of
  .chip-select* rules tied to the existing design tokens.
- frontend/src/__tests__/chip-select.test.ts (NEW, 10 tests):
  trigger render, open/close, select fires onChange, Escape close,
  click-outside close, search-input threshold, case-insensitive
  filter, empty-result hint, setValue + setOptions roundtrip,
  ArrowDown keyboard open.

Verified:
- npm test: 1610/1610 pass (1598 baseline + 12 new tests).
- npm run build: clean. Bundle 514 KiB (+0.4% from 512 baseline,
  well within ±10% gate).
- npm test -- --coverage: All-files coverage 80.8% stmts / 82.5% lines
  (preserved vs 80.76 / 82.40 baseline).
@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

An error occurred during the review process. Please try again later.

📝 Walkthrough

Walkthrough

This PR introduces a reusable chip-select UI component and refactors provider/account filtering from per-tab DOM selects into a global topbar with reactive state subscriptions. It updates dashboard, history, and plans modules to read filter state from the shared state module instead of direct DOM reads, with comprehensive test coverage for all changes.

Changes

Global filter chips and topbar integration

Layer / File(s) Summary
ChipSelect UI component and tests
frontend/src/lib/chip-select.ts, frontend/src/__tests__/chip-select.test.ts
New createChipSelect() exports interfaces for options/config/handle and builds a fully interactive dropdown with trigger, menu, search, keyboard/mouse handling, viewport positioning, and ARIA attributes. Tests cover rendering, selection, keyboard nav, search filtering, empty state, and state APIs.
State subscription infrastructure
frontend/src/state.ts
Adds subscribeProvider() and subscribeAccount() functions with listener registries. Updates setCurrentProvider and setCurrentAccountIDs to detect actual changes before notifying subscribers with error-safe try/catch wrapping.
Topbar filters initialization and tests
frontend/src/topbar-filters.ts, frontend/src/__tests__/topbar-filters.test.ts
Exports initTopbarFilters() that mounts provider and account chip-select components into #topbar-filters slot, wires their onChange to state setters, and orchestrates async account list population. Clears account IDs before fetching new provider accounts. Tests verify chip mount count and Issue #185 regression.
Module refactoring for global filters
frontend/src/dashboard.ts, frontend/src/history.ts, frontend/src/plans.ts, frontend/src/__tests__/dashboard.test.ts, frontend/src/__tests__/history*.test.ts, frontend/src/__tests__/plans.test.ts
Updates dashboard, history, and plans to subscribe to global provider/account changes and read state instead of DOM selects. Replaces setupDashboardHandlers, setupHistoryHandlers, setupPlanHandlers DOM wiring with state.subscribeProvider/Account. Updates loadHistory and loadPlans to read provider/account from state getters. Refactors all affected test mocks to include state functions with appropriate defaults.
HTML structure, styling, and app initialization
frontend/src/index.html, frontend/src/styles/components.css, frontend/src/styles/layout.css, frontend/src/index.ts
Adds #topbar-filters container to topbar and removes provider/account filters from dashboard/plans/history tabs. Adds complete chip-select styling (trigger, menu, search, options, empty state with visual indicators) and topbar layout rules. Calls initTopbarFilters() from app startup.
HTML structure test validation
frontend/src/__tests__/html.test.ts
Removes assertions for removed per-tab filter elements and adds assertion for new global #topbar-filters container.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Suggested labels

impact/many, effort/m

Poem

A chip select blooms bright in the topbar's glow, 🌟
Global filters now share what all tabs must know,
State subscriptions flow where changes ignite,
Dashboard, history, plans—all wired just right! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.48% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main changes: a new chip-select component and global filter chips in the topbar, with task identifiers (#344 T1+T2) providing full context.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-344-ui-revamp-phase2

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/all-users Affects every user effort/s Hours type/feat New capability labels May 12, 2026
@cristim

cristim commented May 12, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

First of 4 PRs for issue #344. Self-contained new lib/chip-select.ts component (no existing-file changes apart from appending CSS rules + a new test file).

Focus areas:

  • ARIA correctness of the listbox + option + active-descendant semantics
  • Keyboard nav edge cases (focus management when search-input is or isn't present)
  • Click-outside vs mousedown event timing
  • The search-filter threshold (>8 options) — defensible default

@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and read the chip-select component and test files
fd -e ts chip-select --exec cat {}

Length of output: 19605


@cristim — here's the focused review across the four areas. I found a mix of correctness bugs and genuine gaps; nothing architectural, all fixable in-place.


1. ARIA — role="listbox" ownership violation

The role="listbox" lives on menu (a div). The role="option" li elements are its grandchildren (a ul sits in between). ARIA requires that option elements be owned directly by their listbox (WAI-ARIA §6.6.12). Screen readers that compute owned children from the ARIA tree will miss the options entirely.

Fix — pick one of:

// Option A: mark the intermediary as presentational
optionsList.setAttribute('role', 'presentation');

// Option B: move role="listbox" to the ul
optionsList.setAttribute('role', 'listbox');
optionsList.setAttribute('aria-label', cfg.label);
// and remove the attribute from `menu`
menu.removeAttribute('role');

Option B is cleaner because it keeps the semantic element carrying the semantic role.


2. ARIA — aria-activedescendant is missing entirely

aria-haspopup="listbox" + aria-expanded are correct. But when the menu is open and keyboard navigation moves the highlight, there is no aria-activedescendant on the trigger pointing at the highlighted option, and no id attributes on the li[role="option"] elements. Screen readers operating in browse/virtual-cursor mode will therefore never announce which option is currently highlighted.

The selected value and the keyboard-focus position are also conflated: aria-selected="true" is only set on the currently chosen option, while the keyboard-highlighted option just gets the CSS class active. These are two separate concepts:

Concept Correct attribute
Which option is selected aria-selected="true" on that option
Which option has keyboard focus aria-activedescendant="<option-id>" on trigger (or listbox)

Minimal fix in renderOptions():

visible.forEach((opt, i) => {
  const li = document.createElement('li');
  const optId = `chip-select-opt-${cfg.label.toLowerCase().replace(/\s+/g, '-')}-${opt.value || 'empty'}-${i}`;
  li.id = optId;
  li.setAttribute('role', 'option');
  // ...
  li.setAttribute('aria-selected', opt.value === currentValue ? 'true' : 'false');
  if (i === activeIndex) {
    li.classList.add('active');
    // set pointer on the trigger (or on the listbox element)
    trigger.setAttribute('aria-activedescendant', optId);
  }
  // ...
});
// Clear when nothing is highlighted
if (activeIndex === -1) trigger.removeAttribute('aria-activedescendant');

And in closeMenu():

trigger.removeAttribute('aria-activedescendant');

3. Keyboard — ArrowDown double-fires on open

Two separate keydown listeners are registered on trigger. When the menu is closed and the user presses ArrowDown:

  1. Listener 1 fires → openMenu() executes → isOpen = true, activeIndex is set to the position of currentValue (say 0), renderOptions() called.
  2. Listener 2 fires on the same event → isOpen is now trueactiveIndex = Math.min(0 + 1, …) → highlight jumps to index 1 instead of staying at 0.

The highlight skips its intended initial position every time ArrowDown opens the menu.

Fix — split the intent: the first listener should only open when closed, and the second should only navigate when already open:

// Listener 1 — open only
trigger.addEventListener('keydown', (ev) => {
  if (isOpen) return;                             // ← guard added
  if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'ArrowDown') {
    ev.preventDefault();
    openMenu();
  }
});

// Listener 2 — navigate only (isOpen guard already present, no change needed)

4. Keyboard — Tab does not close the menu

The JSDoc says "Tab on menu → close (and let focus continue)" but there is no Tab handler on the search input (or anywhere else). After the user presses Tab in the search input:

  • Focus moves to the next DOM element.
  • menu stays visible (hidden class not added).
  • aria-expanded stays "true".
  • The document-level mousedown + escListener remain registered.

Fix — add a keydown guard in the search handler:

search.addEventListener('keydown', (ev) => {
  if (ev.key === 'Tab') {
    closeMenu();   // don't prevent default — let Tab continue to next element
    return;
  }
  // ... existing arrow/enter handling
});

5. ARIA — "No matches" hint needs a valid role inside role="listbox"

const li = document.createElement('li');
li.classList.add('chip-select-empty');
li.textContent = 'No matches';
li.setAttribute('aria-disabled', 'true');
// ← no role

An element inside a role="listbox" that isn't role="option" (or role="group") is invalid per ARIA ownership rules. Either give it role="option" aria-disabled="true" (making it an inert, announced option) or role="presentation" (purely visual). The former is more informative to AT users.


6. Click-outside timing — looks correct ✅

The mousedown-based outside listener is the right approach. Option selection uses mousedown + ev.preventDefault(), which fires and calls selectByValue → closeMenu before the document listener can act. root.contains(ev.target) correctly filters out intra-component clicks. No race condition observed.


7. Search threshold — defensible ✅

DEFAULT_SEARCH_THRESHOLD = 8 (search appears at 9+ options) is a widely used convention (Material, Headless UI both use 5–10). Checking options.length rather than visibleOptions().length ensures the input doesn't disappear mid-search. The searchThreshold escape hatch in the config is a good call.


Summary table

# Area Severity Quick fix
1 role="option" not owned by role="listbox" 🔴 High role="presentation" on ul or move role="listbox" to ul
2 Missing aria-activedescendant + option ids 🔴 High Generate ids in renderOptions, set attribute on trigger
3 ArrowDown double-fires on open 🟠 Medium Guard listener 1 with if (isOpen) return
4 Tab doesn't close menu 🟠 Medium Add Tab guard in search.keydown
5 "No matches" li has no role 🟡 Low Add role="option" aria-disabled="true"
6 Click-outside mousedown timing ✅ OK
7 Search threshold ✅ OK

Relocate the Provider + Account filters from per-section <select>
dropdowns (Home / Plans / Purchases) into a single pair of chip-selects
in the global topbar. The page-level controls had drifted into three
near-identical copies, and changing scope meant clicking the same
filter on each tab in turn — the topbar now owns one source of truth.

Each section subscribes to state.subscribeProvider / subscribeAccount
and reloads itself when the global filter changes. Issue #185's
ordering invariant (clear account state BEFORE awaiting the
account-list refetch on provider change) moves from dashboard.ts to
topbar-filters.ts so it stays at the mutation site.

- new: frontend/src/topbar-filters.ts (initTopbarFilters + chip wiring)
- new: subscribeProvider / subscribeAccount in state.ts (fire on change)
- removed: per-section #dashboard-/plans-/history-{provider,account}-filter
- new: topbar-filters.test.ts (Issue #185 regression coverage)

Tests: 1609 passed / 1 skipped (dashboard's Issue #185 cases moved to
topbar-filters.test.ts). Coverage 80.76% stmts / 82.54% lines (parity
with the post-#343 baseline). Build 519 KiB (within ±10% of 512).
@cristim cristim changed the title feat(frontend/lib): chip-select component for filter pill popovers (issue #344 T1) feat(frontend): chip-select component + global filter chips in topbar (#344 T1+T2) May 13, 2026
@cristim

cristim commented May 13, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented May 13, 2026

Copy link
Copy Markdown
Member Author

pre-commit CI run 25790874399 passed on dc6ed40.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 7

🧹 Nitpick comments (1)
frontend/src/__tests__/chip-select.test.ts (1)

217-230: ⚡ Quick win

Add keyboard regression tests for open-state navigation and Tab close.

Please add focused tests for: (1) ArrowDown opening without advancing past initial active option, and (2) Tab from .chip-select-search closing the menu while allowing focus to proceed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/__tests__/chip-select.test.ts` around lines 217 - 230, Add two
focused keyboard regression tests in the chip-select test suite using
createChipSelect: (1) test that pressing ArrowDown on the trigger opens the menu
but does not advance the active option from the initial selection — query the
trigger ('.chip-select'), open the menu via
KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true}), then assert the menu is
visible and that the initially active option (inspect elements under
'.chip-select-menu' or the option with an 'active' class/aria-selected) remains
unchanged; (2) test that pressing Tab while focus is in the search input
('.chip-select-search') closes the menu but allows focus to move forward — focus
the search element, open the menu, dispatch
KeyboardEvent('keydown',{key:'Tab',bubbles:true}), then assert the menu is
hidden and that document.activeElement is the next focusable element (or not the
search input). Use createChipSelect to build the component and existing query
selectors ('.chip-select', '.chip-select-menu', '.chip-select-search') to locate
elements.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/history.ts`:
- Around line 70-72: setupHistoryHandlers currently calls
state.subscribeProvider and state.subscribeAccount each time without removing
previous listeners, causing duplicate loadHistory() calls; make these
subscriptions idempotent by storing the unsubscribe/cleanup functions returned
by state.subscribeProvider and state.subscribeAccount (or by tracking a boolean
flag) on the module scope and calling them before creating new subscriptions
inside setupHistoryHandlers, then subscribe with wrappers that call
loadHistory(); reference setupHistoryHandlers, state.subscribeProvider,
state.subscribeAccount, and loadHistory when locating and updating the
implementation.

In `@frontend/src/lib/chip-select.ts`:
- Around line 270-275: The ArrowDown key is being handled twice: the trigger
keydown listener calls openMenu() and another keydown listener immediately
advances activeIndex; to prevent skipping the initial highlight, when handling
ArrowDown in the trigger keydown handler (the handler that calls openMenu()),
after calling openMenu() call ev.stopImmediatePropagation() (and keep
ev.preventDefault()) so the subsequent keydown listener won't run; apply the
same change to the other symmetric trigger keydown block around the 303-309
region so both places stop immediate propagation for ArrowDown.
- Around line 283-298: In the search keydown handler (the listener attached with
search.addEventListener('keydown', ...)) add handling for the 'Tab' key so the
menu closes and normal focus movement is preserved: detect ev.key === 'Tab', do
NOT call ev.preventDefault(), and invoke the component's menu-close logic (e.g.,
call the existing close function or set the open state to false — whatever the
component uses to hide options) instead of letting the handler ignore it; keep
the rest of ArrowUp/ArrowDown/Enter behavior unchanged (references: activeIndex,
visibleOptions(), renderOptions(), selectByValue()).
- Around line 81-87: The trigger button created as "trigger" needs ARIA wiring:
assign a stable id to the listbox element and set
trigger.setAttribute('aria-controls', listboxId'); generate stable ids for each
option (e.g., `${baseId}-option-${index}`) when creating option elements, and
whenever the menu is opened or activeIndex changes update
trigger.setAttribute('aria-activedescendant', idOfActiveOption) or remove it
when closed; ensure the code paths that change activeIndex (keyboard handlers,
open/close logic) also call the update so assistive tech can see the highlighted
option.
- Around line 114-129: Move the listbox semantics from the wrapper div to the
actual options container: remove menu.setAttribute('role', 'listbox') and
instead call optionsList.setAttribute('role', 'listbox') while preserving the
aria-label (use cfg.label) on the listbox; also ensure the empty-state "No
matches" row created later is given role="option" (and tabindex/-selection
handling if applicable) so it is a valid child of the listbox. Locate the
variables menu and optionsList in chip-select.ts and update their attribute
calls accordingly, and update the code that renders the no-results row to add
role="option".

In `@frontend/src/plans.ts`:
- Around line 1116-1118: The setupPlanHandlers function currently calls
state.subscribeProvider and state.subscribeAccount each time without keeping
unsubscribe handles, causing duplicate listeners and multiple loadPlans()
invocations on re-entry; update setupPlanHandlers to store the unsubscribe
callbacks returned by state.subscribeProvider and state.subscribeAccount (or
keep them in a module-scope variable) and call those unsubscribe functions
before adding new subscriptions, or make setupPlanHandlers idempotent by
checking if subscriptions already exist and skipping re-subscription, ensuring
loadPlans() is only invoked once per actual state change.

In `@frontend/src/topbar-filters.ts`:
- Around line 38-57: populateAccountOptions can apply stale async responses when
multiple api.listAccounts calls overlap; add a request-sequencing guard:
introduce a module-scoped counter (e.g., let accountRequestCounter = 0),
increment it at the start of populateAccountOptions and capture its value in a
local const (e.g., const req = ++accountRequestCounter), then after awaiting
api.listAccounts(check) return early unless req === accountRequestCounter before
calling accountChip.setOptions; apply the same pattern to the other places in
this file that call api.listAccounts (the other populate/refresh functions
referencing accountChip).

---

Nitpick comments:
In `@frontend/src/__tests__/chip-select.test.ts`:
- Around line 217-230: Add two focused keyboard regression tests in the
chip-select test suite using createChipSelect: (1) test that pressing ArrowDown
on the trigger opens the menu but does not advance the active option from the
initial selection — query the trigger ('.chip-select'), open the menu via
KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true}), then assert the menu is
visible and that the initially active option (inspect elements under
'.chip-select-menu' or the option with an 'active' class/aria-selected) remains
unchanged; (2) test that pressing Tab while focus is in the search input
('.chip-select-search') closes the menu but allows focus to move forward — focus
the search element, open the menu, dispatch
KeyboardEvent('keydown',{key:'Tab',bubbles:true}), then assert the menu is
hidden and that document.activeElement is the next focusable element (or not the
search input). Use createChipSelect to build the component and existing query
selectors ('.chip-select', '.chip-select-menu', '.chip-select-search') to locate
elements.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2cc2c547-b8c9-4d51-83fa-ae7a158cd19b

📥 Commits

Reviewing files that changed from the base of the PR and between 2f4cce5 and dc6ed40.

📒 Files selected for processing (19)
  • frontend/src/__tests__/chip-select.test.ts
  • frontend/src/__tests__/dashboard.test.ts
  • frontend/src/__tests__/history-approve-button.test.ts
  • frontend/src/__tests__/history-cancel-button.test.ts
  • frontend/src/__tests__/history-retry-button.test.ts
  • frontend/src/__tests__/history.test.ts
  • frontend/src/__tests__/html.test.ts
  • frontend/src/__tests__/plans.test.ts
  • frontend/src/__tests__/topbar-filters.test.ts
  • frontend/src/dashboard.ts
  • frontend/src/history.ts
  • frontend/src/index.html
  • frontend/src/index.ts
  • frontend/src/lib/chip-select.ts
  • frontend/src/plans.ts
  • frontend/src/state.ts
  • frontend/src/styles/components.css
  • frontend/src/styles/layout.css
  • frontend/src/topbar-filters.ts

Comment thread frontend/src/history.ts
Comment on lines 70 to +72
export function setupHistoryHandlers(): void {
const providerFilter = document.getElementById('history-provider-filter') as HTMLSelectElement | null;
if (providerFilter) {
providerFilter.addEventListener('change', () => {
void populateHistoryAccountFilter(providerFilter.value);
});
}
void populateHistoryAccountFilter();
state.subscribeProvider(() => void loadHistory());
state.subscribeAccount(() => void loadHistory());

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make history filter subscriptions idempotent to avoid duplicate reloads.

Line 71 and Line 72 register new subscribers every call and never clean up old ones. If setupHistoryHandlers() is called again, each filter change can trigger multiple loadHistory() runs.

Suggested fix
+let unsubscribeHistoryProvider: (() => void) | null = null;
+let unsubscribeHistoryAccount: (() => void) | null = null;
+
 export function setupHistoryHandlers(): void {
-  state.subscribeProvider(() => void loadHistory());
-  state.subscribeAccount(() => void loadHistory());
+  unsubscribeHistoryProvider?.();
+  unsubscribeHistoryAccount?.();
+  unsubscribeHistoryProvider = state.subscribeProvider(() => void loadHistory());
+  unsubscribeHistoryAccount = state.subscribeAccount(() => void loadHistory());
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/history.ts` around lines 70 - 72, setupHistoryHandlers currently
calls state.subscribeProvider and state.subscribeAccount each time without
removing previous listeners, causing duplicate loadHistory() calls; make these
subscriptions idempotent by storing the unsubscribe/cleanup functions returned
by state.subscribeProvider and state.subscribeAccount (or by tracking a boolean
flag) on the module scope and calling them before creating new subscriptions
inside setupHistoryHandlers, then subscribe with wrappers that call
loadHistory(); reference setupHistoryHandlers, state.subscribeProvider,
state.subscribeAccount, and loadHistory when locating and updating the
implementation.

Comment on lines +81 to +87
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.classList.add('chip-select');
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-label', cfg.label);

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

fd -t f "chip-select.ts" --exec cat -n {} \;

Repository: LeanerCloud/CUDly

Length of output: 13773


Add aria-activedescendant wiring for keyboard-highlighted option.

The highlighted option (activeIndex) changes visually but is invisible to assistive technology. Add stable option IDs and sync aria-activedescendant on the trigger while the menu is open and whenever keyboard navigation changes the active index. Also link the trigger to the listbox via aria-controls.

Suggested patch
 export function createChipSelect(cfg: ChipSelectConfig): ChipSelectHandle {
+  const instanceId = `chip-select-${Math.random().toString(36).slice(2)}`;
@@
   trigger.setAttribute('aria-haspopup', 'listbox');
   trigger.setAttribute('aria-expanded', 'false');
   trigger.setAttribute('aria-label', cfg.label);
+  trigger.setAttribute('aria-controls', `${instanceId}-listbox`);
@@
   optionsList.classList.add('chip-select-options');
+  optionsList.id = `${instanceId}-listbox`;
@@
     visible.forEach((opt, i) => {
       const li = document.createElement('li');
+      li.id = `${instanceId}-opt-${i}`;
@@
       if (i === activeIndex) li.classList.add('active');
@@
     });
+    const activeEl = activeIndex >= 0
+      ? optionsList.querySelectorAll<HTMLElement>('.chip-select-option')[activeIndex]
+      : null;
+    if (activeEl) trigger.setAttribute('aria-activedescendant', activeEl.id);
+    else trigger.removeAttribute('aria-activedescendant');
   }
@@
   function closeMenu(): void {
@@
     trigger.setAttribute('aria-expanded', 'false');
+    trigger.removeAttribute('aria-activedescendant');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/lib/chip-select.ts` around lines 81 - 87, The trigger button
created as "trigger" needs ARIA wiring: assign a stable id to the listbox
element and set trigger.setAttribute('aria-controls', listboxId'); generate
stable ids for each option (e.g., `${baseId}-option-${index}`) when creating
option elements, and whenever the menu is opened or activeIndex changes update
trigger.setAttribute('aria-activedescendant', idOfActiveOption) or remove it
when closed; ensure the code paths that change activeIndex (keyboard handlers,
open/close logic) also call the update so assistive tech can see the highlighted
option.

Comment thread frontend/src/lib/chip-select.ts
Comment thread frontend/src/lib/chip-select.ts Outdated
Comment thread frontend/src/lib/chip-select.ts Outdated
Comment thread frontend/src/plans.ts Outdated
Comment on lines +1116 to +1118
export function setupPlanHandlers(): void {
const plansProviderFilter = document.getElementById('plans-provider-filter') as HTMLSelectElement | null;
if (plansProviderFilter) {
plansProviderFilter.addEventListener('change', () => {
void populatePlansAccountFilter(plansProviderFilter.value);
void loadPlans();
});
}

const plansAccountFilter = document.getElementById('plans-account-filter') as HTMLSelectElement | null;
if (plansAccountFilter) {
plansAccountFilter.addEventListener('change', () => void loadPlans());
}

void populatePlansAccountFilter();
state.subscribeProvider(() => void loadPlans());
state.subscribeAccount(() => void loadPlans());

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent duplicate provider/account subscriptions in repeated setup calls.

Line 1117 and Line 1118 add subscriptions without storing/cleaning unsubscribe callbacks. Re-entering setupPlanHandlers() will stack listeners and multiply loadPlans() calls per filter change.

Suggested fix
+let unsubscribePlanProvider: (() => void) | null = null;
+let unsubscribePlanAccount: (() => void) | null = null;
+
 export function setupPlanHandlers(): void {
-  state.subscribeProvider(() => void loadPlans());
-  state.subscribeAccount(() => void loadPlans());
+  unsubscribePlanProvider?.();
+  unsubscribePlanAccount?.();
+  unsubscribePlanProvider = state.subscribeProvider(() => void loadPlans());
+  unsubscribePlanAccount = state.subscribeAccount(() => void loadPlans());

   const providerSelect = document.getElementById('plan-provider') as HTMLSelectElement | null;
   const serviceSelect = document.getElementById('plan-service') as HTMLSelectElement | null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/plans.ts` around lines 1116 - 1118, The setupPlanHandlers
function currently calls state.subscribeProvider and state.subscribeAccount each
time without keeping unsubscribe handles, causing duplicate listeners and
multiple loadPlans() invocations on re-entry; update setupPlanHandlers to store
the unsubscribe callbacks returned by state.subscribeProvider and
state.subscribeAccount (or keep them in a module-scope variable) and call those
unsubscribe functions before adding new subscriptions, or make setupPlanHandlers
idempotent by checking if subscriptions already exist and skipping
re-subscription, ensuring loadPlans() is only invoked once per actual state
change.

Comment on lines +38 to +57
async function populateAccountOptions(provider: string): Promise<void> {
if (!accountChip) return;
try {
const filter =
provider && provider !== '' && provider !== 'all'
? { provider: provider as 'aws' | 'azure' | 'gcp' }
: undefined;
const accounts = await api.listAccounts(filter);
const options: ChipSelectOption[] = [
{ value: '', label: 'All Accounts' },
...accounts.map((a) => ({
value: a.id,
label: `${a.name} (${a.external_id})`,
})),
];
accountChip.setOptions(options);
} catch (err) {
console.warn('topbar-filters: failed to list accounts for chip:', err);
accountChip.setOptions([{ value: '', label: 'All Accounts' }]);
}

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against stale async account-list responses.

Out-of-order listAccounts responses can overwrite the account chip with options for an older provider after the user already switched again.

Suggested patch
 let providerChip: ChipSelectHandle | null = null;
 let accountChip: ChipSelectHandle | null = null;
+let accountReqSeq = 0;
@@
 async function populateAccountOptions(provider: string): Promise<void> {
   if (!accountChip) return;
+  const reqSeq = ++accountReqSeq;
   try {
@@
     const accounts = await api.listAccounts(filter);
+    if (reqSeq !== accountReqSeq) return;
@@
     accountChip.setOptions(options);
   } catch (err) {
+    if (reqSeq !== accountReqSeq) return;
     console.warn('topbar-filters: failed to list accounts for chip:', err);
     accountChip.setOptions([{ value: '', label: 'All Accounts' }]);
   }
 }

Also applies to: 92-94, 113-113

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/topbar-filters.ts` around lines 38 - 57, populateAccountOptions
can apply stale async responses when multiple api.listAccounts calls overlap;
add a request-sequencing guard: introduce a module-scoped counter (e.g., let
accountRequestCounter = 0), increment it at the start of populateAccountOptions
and capture its value in a local const (e.g., const req =
++accountRequestCounter), then after awaiting api.listAccounts(check) return
early unless req === accountRequestCounter before calling
accountChip.setOptions; apply the same pattern to the other places in this file
that call api.listAccounts (the other populate/refresh functions referencing
accountChip).

…ble-fire

Address all five actionable items from the CodeRabbit review of PR #345:

1. ARIA ownership (High): move role="listbox" from the outer div to the ul
   so role="option" elements are directly owned by the listbox, per
   WAI-ARIA §6.6.12.

2. aria-activedescendant (High): generate stable ids for each option li
   and set aria-activedescendant on the trigger while the menu is open,
   pointing at the highlighted option. Clear it in closeMenu().

3. ArrowDown double-fire (Medium): merge the two separate keydown listeners
   on the trigger into a single handler that captures wasOpen before any
   mutation, making open and navigate mutually exclusive for the same event.

4. Tab does not close menu (Medium): add a Tab guard in the search input
   keydown handler; closeMenu() without preventDefault so focus continues.

5. "No matches" role (Low): add role="option" + aria-selected="false" to
   the empty-result hint so it is a valid child of role="listbox".

Add six regression tests covering all five fixes.

Items 6 (click-outside timing) and 7 (search threshold) were confirmed
correct by CodeRabbit and required no changes.
@cristim

cristim commented May 13, 2026

Copy link
Copy Markdown
Member Author

CodeRabbit review — pass 1 addressed (commit e5d253d)

All five actionable items from the CR review have been fixed:

Actionable fixes (commit e5d253dc6):

  • [High] ARIA ownership: moved role="listbox" from the outer div to the ul (optionsList) so role="option" elements are directly owned by the listbox — required by WAI-ARIA §6.6.12.
  • [High] Missing aria-activedescendant: each option li now has a stable id; aria-activedescendant on the trigger tracks the highlighted option during keyboard navigation and is cleared in closeMenu().
  • [Medium] ArrowDown double-fire on open: merged the two separate keydown listeners on the trigger into one handler that captures wasOpen before any mutation, making open and navigate mutually exclusive for the same event.
  • [Medium] Tab doesn't close menu: added a Tab guard in the search input keydown handler; calls closeMenu() without preventDefault so focus continues to the next element.
  • [Low] "No matches" role: added role="option" aria-selected="false" to the empty-result hint — valid child of role="listbox".

Confirmed no-action (per CR):

  • Click-outside mousedown timing: correct.
  • Search threshold DEFAULT_SEARCH_THRESHOLD = 8: defensible.

Coverage: 80.74% stmts / 82.51% lines (baseline: 80.76% / 82.40% — flat/improved).

Six regression tests added for all five fixes.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

Let me read the updated implementation before re-reviewing.


🏁 Script executed:

#!/bin/bash
fd -e ts chip-select --exec cat {}

Length of output: 25214


@cristim — all five fixes verified against the updated chip-select.ts. Everything is correctly implemented.

# Fix Implementation Tests
1 role="listbox" on ul optionsList.setAttribute('role', 'listbox'), no role on menu div, explanatory comment included ✅ asserts ul has role, menu has none
2 aria-activedescendant + stable option ids idPrefix built once per instance; each li gets id; renderOptions sets/clears aria-activedescendant; closeMenu removes it ✅ checks ids non-empty, correct value targeted, cleared on close
3 ArrowDown double-fire Single keydown listener; wasOpen captured before any mutation; !wasOpen branch opens + returns (no navigation path taken) ✅ verifies aws stays highlighted after ArrowDown-open, not advanced to azure
4 Tab closes menu search.keydown guards ev.key === 'Tab'closeMenu() without preventDefault ✅ menu hidden after Tab on search
5 "No matches" role role="option", aria-disabled="true", aria-selected="false" ✅ asserts all three attributes

The aria-selected / aria-activedescendant separation (selected value vs. keyboard-highlighted position) is also correctly modelled — the two concepts don't bleed into each other.

One minor observation for future hardening (not a blocker): the idPrefix is derived from cfg.label, so two concurrent chip-selects with identical labels (e.g., two "Provider" chips) would produce colliding option IDs. For the current Provider + Account use case there's no collision.

✅ Pass 2 — nothing blocking merge.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Address two actionable findings from the CodeRabbit pass-2 review:

1. plans.ts — setupPlanHandlers was adding new subscribeProvider /
   subscribeAccount listeners on every call without cleaning up the prior
   ones, so calling it more than once (tests, HMR) stacks duplicate
   loadPlans() invocations. Store the unsubscribe handles at module scope
   (_unsubProvider / _unsubAccount) and call them before re-registering.

2. topbar-filters.ts — populateAccountOptions had no guard against
   overlapping async api.listAccounts calls; a slower earlier response
   could overwrite the options set by a newer, faster response. Introduce
   a monotonic generation counter (_accountRequestGen); each call captures
   its generation before the await and discards the result if a newer
   call has already started.
@cristim

cristim commented May 13, 2026

Copy link
Copy Markdown
Member Author

CodeRabbit review — pass 2 addressed (commit 605240d)

Actionable fixes:

  • plans.ts subscription leak: setupPlanHandlers now stores unsubscribe handles at module scope and calls them before re-registering, preventing stacked duplicate loadPlans() calls on re-entry.
  • topbar-filters.ts stale async race: populateAccountOptions uses a monotonic generation counter; responses from superseded requests are discarded before calling accountChip.setOptions.

Nitpick (already done in pass 1): ArrowDown double-fire and Tab regression tests were added in commit e5d253d.

Coverage: 80.73% stmts / 82.51% lines (baseline: 80.76% / 82.40%).

@coderabbitai review

@cristim

cristim commented May 13, 2026

Copy link
Copy Markdown
Member Author

CR clean - ready for human review

CodeRabbit pass 3 came back clean (no blocking issues). All actionable items from both review passes have been addressed across two fix commits:

  • e5d253d: ARIA ownership, activedescendant, Tab close, ArrowDown double-fire, No-matches role
  • 605240d: subscription leak in plans.ts, stale async race in topbar-filters.ts

Coverage: 80.73% stmts / 82.51% lines. 45 test suites green.

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -e ts -e tsx plans topbar-filters --exec cat {}

Length of output: 102


🏁 Script executed:

#!/bin/bash
fd '(plans|topbar-filters)\.ts$' --exec cat {}

Length of output: 53723


@cristim — both pass-2 fixes verified against the updated files:

plans.ts subscription leak
_unsubProvider / _unsubAccount are stored at module scope; setupPlanHandlers() calls _unsubProvider?.() and _unsubAccount?.() before re-registering — no duplicate loadPlans() calls on re-entry.

topbar-filters.ts stale async race
_accountRequestGen is incremented and captured as gen before each await api.listAccounts(); both the success and catch paths discard the response with if (gen !== _accountRequestGen) return before calling accountChip.setOptions. The counter is intentionally not reset in initTopbarFilters() — incrementing from its current value still correctly distinguishes old responses from new ones.

Two small observations, neither blocking:

  1. _accountRequestGen is a module-level let (not const), so a test that calls initTopbarFilters() multiple times will keep accumulating. No bug — just worth noting if you later add a full-reset path for testing.
  2. The Issue #185 ordering invariant is intact: state.setCurrentAccountIDs([]) fires synchronously before the await inside populateAccountOptions, so any in-flight reader on the account list sees the cleared value.

✅ Pass 3 — nothing blocking merge.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit 83b09bf into feat/multicloud-web-frontend May 13, 2026
6 checks passed
@cristim
cristim deleted the feat/issue-344-ui-revamp-phase2 branch May 13, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/all-users Affects every user priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant