Fix project button overflow with dropdown menu#1207
Conversation
Greptile SummaryThis PR replaces per-project inline icon buttons with a single three-dot Confidence Score: 4/5Safe to merge after fixing the keyboard-navigation regression for link items. One P1 accessibility defect: keyboard users cannot activate the 'View project website' or 'View repository' items. The remaining findings are P2 style suggestions and do not block merge, but the P1 should be addressed first. app/javascript/pages/Projects/Index.svelte — specifically the two DropdownMenu.Item wrappers around anchor tags (lines 314–360) Important Files Changed
Sequence DiagramsequenceDiagram
actor User
participant Trigger as DropdownMenu.Trigger (3-dot button)
participant Content as DropdownMenu.Content
participant Item as DropdownMenu.Item (div[role=menuitem])
participant AnchorTag as <a> (nested child)
User->>Trigger: Click / Enter
Trigger->>Content: Opens dropdown portal
Note over User,Item: Mouse path (works)
User->>AnchorTag: Mouse click
AnchorTag-->>User: Navigate to URL ✓
Note over User,Item: Keyboard path (broken)
User->>Item: Arrow key highlight + Enter
Item->>Item: bits-ui fires synthetic click on div
Item--xAnchorTag: Navigation NOT triggered ✗
Prompt To Fix All With AIThis is a comment left during a code review.
Path: app/javascript/pages/Projects/Index.svelte
Line: 314-336
Comment:
**Keyboard navigation won't activate link items**
`DropdownMenu.Item` renders as a `div[role="menuitem"]`; when a keyboard user presses Enter/Space on the highlighted item, bits-ui fires a synthetic click on the `div`, *not* on the inner `<a>`. Navigation therefore silently fails for keyboard-only users. The same pattern is repeated for the "View repository" item (line 339–360).
The idiomatic fix is to use `asChild` so the item delegates its role/props onto the `<a>` itself:
```svelte
<DropdownMenu.Item asChild>
{#snippet child({ props })}
<a
{...props}
href={project.repository.homepage}
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none"
>
<!-- icon + label -->
</a>
{/snippet}
</DropdownMenu.Item>
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: app/javascript/pages/Projects/Index.svelte
Line: 292-313
Comment:
**Dropdown trigger visible even when no actions exist**
If a project has no `repository?.homepage`, no `repo_url`, `manage_enabled` is falsy, and neither `archive_path` nor `unarchive_path` is set, the three-dot trigger button still renders but opens an empty menu. Consider conditionally rendering the trigger only when at least one action is available.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: app/javascript/pages/Projects/Index.svelte
Line: 314-451
Comment:
**Duplicated item class string — extract to a variable**
The class `"flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none"` is copy-pasted five times. The existing `cardActionClass` variable shows the pattern; a similar `dropdownItemClass` const would keep changes consistent and reduce noise.
```svelte
const dropdownItemClass = "flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none";
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "Replace project card action buttons with..." | Re-trigger Greptile |
| <DropdownMenu.Item | ||
| class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none" | ||
| > | ||
| <a | ||
| href={project.repository.homepage} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="flex w-full items-center gap-2" | ||
| > | ||
| <svg | ||
| class="h-4 w-4 shrink-0" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| viewBox="0 0 24 24" | ||
| > | ||
| <path | ||
| fill="currentColor" | ||
| d="M16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2m-5.15 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 0 1-4.33 3.56M14.34 14H9.66c-.1-.66-.16-1.32-.16-2s.06-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2M12 19.96c-.83-1.2-1.5-2.53-1.91-3.96h3.82c-.41 1.43-1.08 2.76-1.91 3.96M8 8H5.08A7.92 7.92 0 0 1 9.4 4.44C8.8 5.55 8.35 6.75 8 8m-2.92 8H8c.35 1.25.8 2.45 1.4 3.56A8 8 0 0 1 5.08 16m-.82-2C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2M12 4.03c.83 1.2 1.5 2.54 1.91 3.97h-3.82c.41-1.43 1.08-2.77 1.91-3.97M18.92 8h-2.95a15.7 15.7 0 0 0-1.38-3.56c1.84.63 3.37 1.9 4.33 3.56M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2" | ||
| /> | ||
| </svg> | ||
| View project website | ||
| </a> | ||
| </DropdownMenu.Item> |
There was a problem hiding this comment.
Keyboard navigation won't activate link items
DropdownMenu.Item renders as a div[role="menuitem"]; when a keyboard user presses Enter/Space on the highlighted item, bits-ui fires a synthetic click on the div, not on the inner <a>. Navigation therefore silently fails for keyboard-only users. The same pattern is repeated for the "View repository" item (line 339–360).
The idiomatic fix is to use asChild so the item delegates its role/props onto the <a> itself:
<DropdownMenu.Item asChild>
{#snippet child({ props })}
<a
{...props}
href={project.repository.homepage}
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none"
>
<!-- icon + label -->
</a>
{/snippet}
</DropdownMenu.Item>Prompt To Fix With AI
This is a comment left during a code review.
Path: app/javascript/pages/Projects/Index.svelte
Line: 314-336
Comment:
**Keyboard navigation won't activate link items**
`DropdownMenu.Item` renders as a `div[role="menuitem"]`; when a keyboard user presses Enter/Space on the highlighted item, bits-ui fires a synthetic click on the `div`, *not* on the inner `<a>`. Navigation therefore silently fails for keyboard-only users. The same pattern is repeated for the "View repository" item (line 339–360).
The idiomatic fix is to use `asChild` so the item delegates its role/props onto the `<a>` itself:
```svelte
<DropdownMenu.Item asChild>
{#snippet child({ props })}
<a
{...props}
href={project.repository.homepage}
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none"
>
<!-- icon + label -->
</a>
{/snippet}
</DropdownMenu.Item>
```
How can I resolve this? If you propose a fix, please make it concise.| <DropdownMenu.Root> | ||
| <DropdownMenu.Trigger | ||
| class={cardActionClass} | ||
| aria-label="Project actions" | ||
| > | ||
| <svg | ||
| class="h-5 w-5" | ||
| fill="currentColor" | ||
| viewBox="0 0 24 24" | ||
| aria-hidden="true" | ||
| > | ||
| <svg | ||
| class="h-5 w-5" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| viewBox="0 0 24 24" | ||
| aria-hidden="true" | ||
| > | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M10 14l-3-3 3-3" | ||
| /> | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M7 11h9a4 4 0 0 1 0 8h-2" | ||
| /> | ||
| </svg> | ||
| </Button> | ||
| {:else if !show_archived && project.archive_path} | ||
| <Button | ||
| type="button" | ||
| unstyled | ||
| class={cardActionClass} | ||
| title="Archive project" | ||
| onclick={() => openStatusChangeModal(project, false)} | ||
| <circle cx="12" cy="6" r="2" /> | ||
| <circle cx="12" cy="12" r="2" /> | ||
| <circle cx="12" cy="18" r="2" /> | ||
| </svg> | ||
| </DropdownMenu.Trigger> | ||
| <DropdownMenu.Portal> | ||
| <DropdownMenu.Content | ||
| class="z-50 min-w-[180px] rounded-xl border border-surface-200 bg-dark py-1.5 px-1 shadow-lg" | ||
| sideOffset={8} | ||
| > | ||
| <svg | ||
| class="h-5 w-5" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| viewBox="0 0 24 24" | ||
| aria-hidden="true" | ||
| > | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M3 7h18l-2 11H5z" | ||
| /> | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M8 7V4h8v3" | ||
| /> | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M10 11h4" | ||
| /> | ||
| </svg> | ||
| </Button> | ||
| {/if} | ||
| </div> | ||
| {#if project.repository?.homepage} |
There was a problem hiding this comment.
Dropdown trigger visible even when no actions exist
If a project has no repository?.homepage, no repo_url, manage_enabled is falsy, and neither archive_path nor unarchive_path is set, the three-dot trigger button still renders but opens an empty menu. Consider conditionally rendering the trigger only when at least one action is available.
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/javascript/pages/Projects/Index.svelte
Line: 292-313
Comment:
**Dropdown trigger visible even when no actions exist**
If a project has no `repository?.homepage`, no `repo_url`, `manage_enabled` is falsy, and neither `archive_path` nor `unarchive_path` is set, the three-dot trigger button still renders but opens an empty menu. Consider conditionally rendering the trigger only when at least one action is available.
How can I resolve this? If you propose a fix, please make it concise.| <DropdownMenu.Item | ||
| class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none" | ||
| > | ||
| <a | ||
| href={project.repository.homepage} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="flex w-full items-center gap-2" | ||
| > | ||
| <svg | ||
| class="h-4 w-4 shrink-0" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| viewBox="0 0 24 24" | ||
| > | ||
| <path | ||
| fill="currentColor" | ||
| d="M16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2m-5.15 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 0 1-4.33 3.56M14.34 14H9.66c-.1-.66-.16-1.32-.16-2s.06-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2M12 19.96c-.83-1.2-1.5-2.53-1.91-3.96h3.82c-.41 1.43-1.08 2.76-1.91 3.96M8 8H5.08A7.92 7.92 0 0 1 9.4 4.44C8.8 5.55 8.35 6.75 8 8m-2.92 8H8c.35 1.25.8 2.45 1.4 3.56A8 8 0 0 1 5.08 16m-.82-2C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2M12 4.03c.83 1.2 1.5 2.54 1.91 3.97h-3.82c.41-1.43 1.08-2.77 1.91-3.97M18.92 8h-2.95a15.7 15.7 0 0 0-1.38-3.56c1.84.63 3.37 1.9 4.33 3.56M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2" | ||
| /> | ||
| </svg> | ||
| View project website | ||
| </a> | ||
| </DropdownMenu.Item> | ||
| {/if} | ||
| {#if project.repo_url} | ||
| <DropdownMenu.Item | ||
| class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none" | ||
| > | ||
| <a | ||
| href={project.repo_url} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="flex w-full items-center gap-2" | ||
| > | ||
| <svg | ||
| class="h-4 w-4 shrink-0" | ||
| fill="currentColor" | ||
| viewBox="0 0 24 24" | ||
| > | ||
| <path | ||
| fill="currentColor" | ||
| d="M2.6 10.59L8.38 4.8l1.69 1.7c-.24.85.15 1.78.93 2.23v5.54c-.6.34-1 .99-1 1.73a2 2 0 0 0 2 2a2 2 0 0 0 2-2c0-.74-.4-1.39-1-1.73V9.41l2.07 2.09c-.07.15-.07.32-.07.5a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2c-.18 0-.35 0-.5.07L13.93 7.5a1.98 1.98 0 0 0-1.15-2.34c-.43-.16-.88-.2-1.28-.09L9.8 3.38l.79-.78c.78-.79 2.04-.79 2.82 0l7.99 7.99c.79.78.79 2.04 0 2.82l-7.99 7.99c-.78.79-2.04.79-2.82 0L2.6 13.41c-.79-.78-.79-2.04 0-2.82" | ||
| /> | ||
| </svg> | ||
| View repository | ||
| </a> | ||
| </DropdownMenu.Item> | ||
| {/if} | ||
| {#if project.manage_enabled} | ||
| <DropdownMenu.Item | ||
| class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none" | ||
| onclick={() => openMappingEditor(project)} | ||
| > | ||
| <svg | ||
| class="h-4 w-4 shrink-0" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| viewBox="0 0 24 24" | ||
| aria-hidden="true" | ||
| > | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M16.862 3.487a2.1 2.1 0 0 1 2.97 2.97L9.75 16.54 6 17.25l.71-3.75z" | ||
| /> | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M14.5 5.85l3.65 3.65" | ||
| /> | ||
| </svg> | ||
| {project.repo_url | ||
| ? "Edit mapping" | ||
| : "Link repository"} | ||
| </DropdownMenu.Item> | ||
| {/if} | ||
| {#if show_archived && project.unarchive_path} | ||
| <DropdownMenu.Item | ||
| class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none" | ||
| onclick={() => openStatusChangeModal(project, true)} | ||
| > | ||
| <svg | ||
| class="h-4 w-4 shrink-0" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| viewBox="0 0 24 24" | ||
| aria-hidden="true" | ||
| > | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M10 14l-3-3 3-3" | ||
| /> | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M7 11h9a4 4 0 0 1 0 8h-2" | ||
| /> | ||
| </svg> | ||
| Restore project | ||
| </DropdownMenu.Item> | ||
| {:else if !show_archived && project.archive_path} | ||
| <DropdownMenu.Item | ||
| class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none" | ||
| onclick={() => openStatusChangeModal(project, false)} | ||
| > | ||
| <svg | ||
| class="h-4 w-4 shrink-0" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| viewBox="0 0 24 24" | ||
| aria-hidden="true" | ||
| > | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M3 7h18l-2 11H5z" | ||
| /> | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M8 7V4h8v3" | ||
| /> | ||
| <path | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| stroke-width="2" | ||
| d="M10 11h4" | ||
| /> | ||
| </svg> | ||
| Archive project | ||
| </DropdownMenu.Item> |
There was a problem hiding this comment.
Duplicated item class string — extract to a variable
The class "flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none" is copy-pasted five times. The existing cardActionClass variable shows the pattern; a similar dropdownItemClass const would keep changes consistent and reduce noise.
const dropdownItemClass = "flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none";Prompt To Fix With AI
This is a comment left during a code review.
Path: app/javascript/pages/Projects/Index.svelte
Line: 314-451
Comment:
**Duplicated item class string — extract to a variable**
The class `"flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none"` is copy-pasted five times. The existing `cardActionClass` variable shows the pattern; a similar `dropdownItemClass` const would keep changes consistent and reduce noise.
```svelte
const dropdownItemClass = "flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none";
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Pull request overview
Updates the Projects index card actions UI to prevent button overflow by consolidating per-project actions into a single dropdown menu.
Changes:
- Replaces the inline action icon group (website/repo/edit/archive) with a
bits-uiDropdownMenu. - Adds a “more actions” trigger (3-dot icon) and renders project actions as menu items.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <svg | ||
| class="h-4 w-4 shrink-0" | ||
| fill="currentColor" | ||
| viewBox="0 0 24 24" |
There was a problem hiding this comment.
The SVG icons inside the menu items that already have visible text (“View project website” / “View repository”) should be marked decorative (e.g., aria-hidden="true") to avoid redundant/verbose screen reader output.
| viewBox="0 0 24 24" | |
| viewBox="0 0 24 24" | |
| aria-hidden="true" |
| <DropdownMenu.Item | ||
| class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none" | ||
| > | ||
| <a | ||
| href={project.repo_url} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="flex w-full items-center gap-2" | ||
| > | ||
| <svg | ||
| class="h-4 w-4 shrink-0" | ||
| fill="currentColor" | ||
| viewBox="0 0 24 24" | ||
| > | ||
| <path | ||
| fill="currentColor" | ||
| d="M2.6 10.59L8.38 4.8l1.69 1.7c-.24.85.15 1.78.93 2.23v5.54c-.6.34-1 .99-1 1.73a2 2 0 0 0 2 2a2 2 0 0 0 2-2c0-.74-.4-1.39-1-1.73V9.41l2.07 2.09c-.07.15-.07.32-.07.5a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2c-.18 0-.35 0-.5.07L13.93 7.5a1.98 1.98 0 0 0-1.15-2.34c-.43-.16-.88-.2-1.28-.09L9.8 3.38l.79-.78c.78-.79 2.04-.79 2.82 0l7.99 7.99c.79.78.79 2.04 0 2.82l-7.99 7.99c-.78.79-2.04.79-2.82 0L2.6 13.41c-.79-.78-.79-2.04 0-2.82" | ||
| /> | ||
| </svg> | ||
| View repository | ||
| </a> | ||
| </DropdownMenu.Item> | ||
| {/if} | ||
| {#if project.manage_enabled} | ||
| <DropdownMenu.Item | ||
| class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none" | ||
| onclick={() => openMappingEditor(project)} | ||
| > |
There was a problem hiding this comment.
The DropdownMenu.Item styling class string is duplicated across each item. Consider extracting it into a constant (similar to cardActionClass) to reduce repetition and make future styling changes safer.
| <DropdownMenu.Item | ||
| class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-content data-highlighted:bg-surface-content/10 focus-visible:outline-none" | ||
| > | ||
| <a | ||
| href={project.repository.homepage} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="flex w-full items-center gap-2" | ||
| > |
There was a problem hiding this comment.
DropdownMenu.Item wraps an <a> element. This creates nested/focusable interactive elements and can break dropdown keyboard behavior (roving focus/Enter activation may not trigger the link). Prefer rendering the link as the menu item itself (e.g., use the component’s asChild/link API so the <a> is the root element) instead of nesting an anchor inside the item.
No description provided.