Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/frontend-ui-audit-2026-07-24/DropdownActionRows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Frontend UI Audit — Dropdown Action Rows

**Files:** `src/components/Dropdown/DropdownItem.tsx`, `src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx`, `src/modules/MainApp/WorkManagement/GitHubWorkItemControls.tsx`, `src/modules/MainApp/WorkManagement/GitHubWorkItemList.tsx`, `src/modules/WorkStation/shared/StatusBar/GitSyncStatusMenu.tsx`, `src/modules/WorkStation/shared/TerminalNewSessionSplitButton.tsx`
**Date:** 2026-07-24

## D1 — Raw HTML vs Design System

| Line | Element | Verdict | Reason | Suggested change |
| --- | --- | --- | --- | --- |
| Four action-menu callers | raw `<button>` rows duplicating `DROPDOWN_CLASSES.item` / `menuActionItem` | fixed | The rows repeated the same height, padding, hover, disabled, icon, label, and suffix contract in four product surfaces. | Replaced with shared `DropdownItem` action rows. |
| `DropdownItem.tsx:213` | role-aware `<div>` row | keep with reason | One root must support parent-managed listbox options and directly-focusable action menus. The shared row owns role, tab order, disabled state, and keyboard activation consistently; switching element type per role would split ref and layout behavior. | — |
| `GitHubWorkItemList.tsx:9` | React runtime import | keep with reason | This line changes no rendered element; it makes the existing server-rendered Work Item control tests use the same JSX runtime successfully. | — |

## D2 — Arbitrary Tailwind Value vs Token

| Line | Value | Verdict | Reason | Suggested change |
| --- | --- | --- | --- | --- |
| Changed action rows | row height, spacing, hover, selected, and disabled classes | fixed | Callers no longer rebuild token-backed dropdown styling with local class strings. | `DropdownItem` composes `DROPDOWN_CLASSES.item` and `DROPDOWN_ITEM` tokens. |
| `GitHubWorkItemControls.tsx` | existing `min-w-[180px]` panel width | keep with reason | The width belongs to the existing issue action panel and was not introduced or changed by this migration. | — |

## D3 — Hardcoded Sizes / Colors

| Line | Value | Verdict | Reason | Suggested change |
| --- | --- | --- | --- | --- |
| Action-menu icons | icon size and colors | fixed | Migrated rows source icon sizing from `DROPDOWN_ITEM.iconSize` and text colors from `DropdownItem` state instead of repeating caller-owned styling. | — |

## D4 — Accessibility

| Line | Element | Verdict | Reason | Suggested change |
| --- | --- | --- | --- | --- |
| `DropdownItem.tsx:183-227` | action row semantics | fixed | The shared component now supports `menuitem`, direct tab focus, Enter/Space activation, accessible names/submenu state, and removal of disabled rows from the tab order. `aria-selected` remains limited to listbox options. | — |
| Migrated menu callers | disabled/action rows | fixed | All four callers now use the same keyboard and disabled contract instead of relying on site-specific raw-button behavior and classes. | — |

## D5 — Visual Patterns Observed

- The four changed callers are a valid shared-pattern migration: they all render full-width action rows and do not need custom geometry.
- A repository sweep found 23 remaining `DROPDOWN_CLASSES.menuActionItem` call sites. Several are submenu triggers or label/control rows with `justify-between`, so they should be reviewed as one follow-up sweep rather than converted site-by-site without extending the shared suffix/submenu contract.
- No new global component is required; the follow-up candidate is broader adoption of the extended `DropdownItem`.

## Summary

- 6 fixes applied
- 3 patterns kept with documented reason
- 1 systematic sweep candidate (23 remaining `menuActionItem` sites)
- 0 new abstraction candidates
60 changes: 60 additions & 0 deletions src/components/Dropdown/DropdownItem.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import DropdownItem, { type DropdownItemProps } from "./DropdownItem";

const TestDropdownItem = DropdownItem as unknown as React.ComponentType<
Omit<DropdownItemProps, "children">
>;

describe("DropdownItem accessibility contract", () => {
it("renders a focusable full-width action menu row without option state", () => {
const markup = renderToStaticMarkup(
React.createElement(
TestDropdownItem,
{
role: "menuitem",
fullWidth: true,
tabIndex: 0,
},
"Open"
)
);

expect(markup).toContain('role="menuitem"');
expect(markup).toContain('tabindex="0"');
expect(markup).toContain("w-full");
expect(markup).not.toContain("aria-selected");
});

it("removes disabled action rows from the tab order", () => {
const markup = renderToStaticMarkup(
React.createElement(
TestDropdownItem,
{
role: "menuitem",
tabIndex: 0,
disabled: true,
},
"Delete"
)
);

expect(markup).toContain('tabindex="-1"');
expect(markup).toContain('aria-disabled="true"');
});

it("preserves listbox option selection semantics by default", () => {
const markup = renderToStaticMarkup(
React.createElement(
TestDropdownItem,
{ selected: true },
"Selected option"
)
);

expect(markup).toContain('role="option"');
expect(markup).toContain('aria-selected="true"');
});
});
68 changes: 66 additions & 2 deletions src/components/Dropdown/DropdownItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,43 @@ export interface DropdownItemProps {
* Additional style
*/
style?: React.CSSProperties;

/**
* ARIA role for the row. Defaults to "option" for listbox-style dropdowns.
* Use "menuitem" for action/command menus (context menus, header menus).
* @default "option"
*/
role?: React.AriaRole;

/**
* Full-width action-row layout (w-full, left-aligned, single line). Use for
* command/action menu rows that previously used a raw `<button>` +
* `DROPDOWN_CLASSES.menuActionItem`.
* @default false
*/
fullWidth?: boolean;

/**
* Tab index. Provide `0` to make a standalone action row directly
* keyboard-focusable (menus without a listbox roving-focus manager). Omitted
* by default so listbox options keep parent-managed focus behavior.
*/
tabIndex?: number;

/**
* Accessible name for rows without readable text content (icon-only rows).
*/
ariaLabel?: string;

/**
* `aria-haspopup` for rows that open a submenu / flyout.
*/
ariaHasPopup?: React.AriaAttributes["aria-haspopup"];

/**
* `aria-expanded` for submenu-trigger rows.
*/
ariaExpanded?: boolean;
}

const DropdownItemInner = forwardRef<HTMLDivElement, DropdownItemProps>(
Expand All @@ -129,6 +166,12 @@ const DropdownItemInner = forwardRef<HTMLDivElement, DropdownItemProps>(
className = "",
dataTestId,
style,
role = "option",
fullWidth = false,
tabIndex,
ariaLabel,
ariaHasPopup,
ariaExpanded,
},
ref
) => {
Expand All @@ -137,8 +180,21 @@ const DropdownItemInner = forwardRef<HTMLDivElement, DropdownItemProps>(
onClick?.();
};

// Keyboard activation for directly-focusable rows (action/command menus
// without a listbox roving-focus manager). No-op for parent-managed
// listbox options: they don't set `tabIndex`, so the row never gains focus
// and never receives these key events.
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick?.();
}
};

const itemClasses = [
DROPDOWN_CLASSES.item,
fullWidth && "w-full justify-start whitespace-nowrap text-left",
!disabled && DROPDOWN_CLASSES.itemHover,
// Only keyboard `highlighted` gets a filled background. The `selected`
// state is shown by the checkmark + primary-6 text only (no bg fill).
Expand All @@ -150,16 +206,24 @@ const DropdownItemInner = forwardRef<HTMLDivElement, DropdownItemProps>(
.filter(Boolean)
.join(" ");

const effectiveTabIndex =
tabIndex === undefined ? undefined : disabled ? -1 : tabIndex;

return (
<div
ref={ref}
className={itemClasses}
data-testid={dataTestId}
style={style}
onClick={handleClick}
onKeyDown={handleKeyDown}
onMouseEnter={onMouseEnter}
role="option"
aria-selected={selected}
role={role}
tabIndex={effectiveTabIndex}
aria-label={ariaLabel}
aria-haspopup={ariaHasPopup}
aria-expanded={ariaExpanded}
aria-selected={role === "option" ? selected : undefined}
aria-disabled={disabled}
>
{/* Icon */}
Expand Down
Loading
Loading