Task #491: center quick-actions surface + frame the selected row

The user reported that the small quick-actions popover (which holds
the Postpone trigger) was hard to find — it floated next to the
clicked row and they sometimes lost track of which meeting they
were about to act on. They asked for the surface to appear in the
center of the screen, and for the row they clicked to be clearly
framed in a color while the surface is open.

Changes:
- Replaced the row's row-anchored Popover (PopoverAnchor +
  PopoverContent) with a centered Dialog (DialogContent) that
  Radix renders into a portal. The Dialog uses the existing
  `executiveMeetings.quickActions.label` translation key for its
  title (already present in both Arabic and English) and reuses
  #490's polished Postpone Button unchanged. Test ID stays
  `em-row-quick-${id}` so existing e2e specs continue to target
  the same surface.
- Added a third inset-shadow ring (`inset 0 0 0 3px
  ${highlightColor}`) to the row's composed boxShadow stack while
  the row's quickOpen state is true. Listed first in the stack so
  it becomes the outermost frame and wins visually over the
  existing current-meeting / bulk-select rings. Uses the
  page-level highlightPrefs.color so the frame matches the user's
  chosen accent. Also exposes `data-quick-open="true"` on the
  <tr> for tests.
- Updated the row-quick-actions e2e: the postpone-end-to-end test
  now asserts `role="dialog"` on the surface and the row carries
  `data-quick-open` while open and loses it after Postpone is
  clicked. Added a separate test for Escape closing the surface
  and clearing the row frame.

Verified: 5/5 quick-actions tests pass, plus both drag specs
(row-drag + iPad touch-reorder) still pass.
This commit is contained in:
riyadhafraa
2026-05-11 13:22:58 +00:00
parent c5ae9e8832
commit f1c9ef71b7
2 changed files with 116 additions and 59 deletions
@@ -4203,11 +4203,30 @@ function MeetingRow({
// left to right (top of stack first), so we list the selection ring
// first so it's the visible outer outline. Color is the same brand
// navy used elsewhere in the page so it reads as a deliberate state.
// #486 → #491: Quick-actions surface. Clicking anywhere on the row
// that is NOT an interactive control (button, link, input,
// contenteditable cell, drag handle, row-actions menu, edit
// affordance, bulk-select checkbox, etc.) opens it. #491 promoted
// this from a row-anchored popover to a centered Dialog so the
// action is easy to find regardless of where the row is on screen,
// and added a coloured frame around the selected row while the
// dialog is open. Gated on raw `quickActionsCanMutate` (not
// editMode) per spec.
const [quickOpen, setQuickOpen] = useState(false);
const selectionShadow = bulkSelected ? "inset 0 0 0 2px #0B1E3F" : null;
const currentShadow = isCurrent
? `inset 0 0 0 2px ${highlightColor}`
: null;
const composedShadow = [selectionShadow, currentShadow]
// #491: while the row's quick-actions surface is open we draw a
// thicker accented frame around the row so the user can see at a
// glance which meeting they're about to act on. Listed FIRST so it
// becomes the outermost ring and visibly wins over the current-
// meeting / bulk-select inner rings while open.
const quickOpenShadow = quickOpen
? `inset 0 0 0 3px ${highlightColor}`
: null;
const composedShadow = [quickOpenShadow, selectionShadow, currentShadow]
.filter(Boolean)
.join(", ");
const trStyle: CSSProperties = {
@@ -4217,14 +4236,6 @@ function MeetingRow({
opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
boxShadow: composedShadow || undefined,
};
// #486: Quick-actions popover. Clicking anywhere on the row that is
// NOT an interactive control (button, link, input, contenteditable
// cell, drag handle, row-actions menu, edit affordance, bulk-select
// checkbox, etc.) opens the popover. Anchored to the row itself so
// the menu floats next to the clicked row regardless of scroll.
// Gated on raw `quickActionsCanMutate` (not editMode) per spec.
const [quickOpen, setQuickOpen] = useState(false);
const handleRowClick = useCallback(
(e: React.MouseEvent<HTMLTableRowElement>) => {
if (!quickActionsCanMutate) return;
@@ -4267,60 +4278,73 @@ function MeetingRow({
) {
return;
}
// Don't re-trigger when a click bubbles up from the popover
// content itself.
if (target.closest(`[data-testid="em-row-quick-${meeting.id}"]`)) return;
// #491: the quick-actions surface is now a Dialog rendered via
// Radix's portal, so clicks inside the surface no longer bubble
// through this row's onClick — the old `closest('em-row-quick-')`
// guard against re-trigger from popover content is no longer
// needed.
setQuickOpen(true);
},
[meeting.id, quickActionsCanMutate],
);
return (
<Popover open={quickOpen} onOpenChange={setQuickOpen}>
<PopoverAnchor asChild>
<tr
ref={setNodeRef}
className={`group${dragEnabled ? " cursor-grab active:cursor-grabbing" : ""}`}
style={trStyle}
data-testid={`em-row-${meeting.id}`}
data-current-meeting={isCurrent ? "true" : undefined}
data-bulk-selected={bulkSelected ? "true" : undefined}
onClick={quickActionsCanMutate ? handleRowClick : undefined}
{...(dragEnabled ? attributes : {})}
{...(safeRowDragListeners ?? {})}
>
{renderCells()}
</tr>
</PopoverAnchor>
<>
<tr
ref={setNodeRef}
className={`group${dragEnabled ? " cursor-grab active:cursor-grabbing" : ""}`}
style={trStyle}
data-testid={`em-row-${meeting.id}`}
data-current-meeting={isCurrent ? "true" : undefined}
data-bulk-selected={bulkSelected ? "true" : undefined}
data-quick-open={quickOpen ? "true" : undefined}
onClick={quickActionsCanMutate ? handleRowClick : undefined}
{...(dragEnabled ? attributes : {})}
{...(safeRowDragListeners ?? {})}
>
{renderCells()}
</tr>
{quickActionsCanMutate ? (
<PopoverContent
className="w-auto p-2"
align={isRtl ? "end" : "start"}
side="bottom"
sideOffset={6}
data-testid={`em-row-quick-${meeting.id}`}
onClick={(e) => e.stopPropagation()}
onInteractOutside={() => setQuickOpen(false)}
>
{/* #490: polished Postpone button primary-styled, larger
tap target for iPad, icon flips with locale via flex
direction so it stays adjacent to the label in RTL. */}
<Button
type="button"
variant="default"
size="sm"
onClick={() => {
setQuickOpen(false);
onQuickPostpone?.();
}}
className={`min-w-[11rem] gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
data-testid={`em-row-quick-postpone-${meeting.id}`}
// #491: centered Dialog (was a row-anchored Popover). Renders
// through Radix's portal so the surface always lands in the
// middle of the viewport regardless of where the row sits, and
// the row outline (boxShadow above) makes the originating row
// unmistakable. Test ID kept as `em-row-quick-${id}` so existing
// e2e specs continue to target the same surface and Postpone
// trigger.
<Dialog open={quickOpen} onOpenChange={setQuickOpen}>
<DialogContent
className="sm:max-w-sm"
data-testid={`em-row-quick-${meeting.id}`}
dir={isRtl ? "rtl" : "ltr"}
>
<Clock className="w-4 h-4" aria-hidden="true" />
<span>{t("executiveMeetings.quickActions.postpone")}</span>
</Button>
</PopoverContent>
<DialogHeader>
<DialogTitle className={isRtl ? "text-right" : "text-left"}>
{t("executiveMeetings.quickActions.label")}
</DialogTitle>
</DialogHeader>
{/* #490: polished Postpone button primary-styled, larger
tap target for iPad, icon flips with locale via flex
direction so it stays adjacent to the label in RTL. */}
<div className="flex justify-center pt-2">
<Button
type="button"
variant="default"
size="sm"
onClick={() => {
setQuickOpen(false);
onQuickPostpone?.();
}}
className={`min-w-[11rem] gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
data-testid={`em-row-quick-postpone-${meeting.id}`}
>
<Clock className="w-4 h-4" aria-hidden="true" />
<span>{t("executiveMeetings.quickActions.postpone")}</span>
</Button>
</div>
</DialogContent>
</Dialog>
) : null}
</Popover>
</>
);
}
@@ -147,10 +147,20 @@ test("Postpone quick-action runs a 5-minute postpone end-to-end", async ({
timeout: 10_000,
});
await page.locator(`[data-testid="em-row-${id}"]`).click();
const popover = page.locator(`[data-testid="em-row-quick-${id}"]`);
await expect(popover).toBeVisible();
await popover.locator(`[data-testid="em-row-quick-postpone-${id}"]`).click();
const row = page.locator(`[data-testid="em-row-${id}"]`);
await row.click();
const surface = page.locator(`[data-testid="em-row-quick-${id}"]`);
await expect(surface).toBeVisible();
// #491: surface is now a centered Dialog (Radix portals it under
// `[role='dialog']`), not a row-anchored Popover. While the surface
// is open, the originating row carries `data-quick-open="true"` so
// it shows the coloured frame.
await expect(surface).toHaveAttribute("role", "dialog");
await expect(row).toHaveAttribute("data-quick-open", "true");
await surface.locator(`[data-testid="em-row-quick-postpone-${id}"]`).click();
// After clicking Postpone the quick-actions surface closes and the
// row's selected-frame marker disappears.
await expect(row).not.toHaveAttribute("data-quick-open", "true");
const dialog = page.locator('[data-testid="postpone-dialog"]');
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.locator('[data-testid="postpone-chip-5"]').click();
@@ -162,6 +172,29 @@ test("Postpone quick-action runs a 5-minute postpone end-to-end", async ({
expect(after.end_time).toBe("13:35:00");
});
test("Escape closes the centered surface AND clears the selected-row frame", async ({
page,
}) => {
const id = await insertMeeting({
date: DATE_POSTPONE,
titleEn: "QA Frame Clear",
startTime: "16:00:00",
endTime: "16:30:00",
});
await loginViaUi(page, "admin", "admin123");
await gotoTestDate(page, DATE_POSTPONE);
const row = page.locator(`[data-testid="em-row-${id}"]`);
await expect(row).toBeVisible({ timeout: 10_000 });
await row.click();
const surface = page.locator(`[data-testid="em-row-quick-${id}"]`);
await expect(surface).toBeVisible();
await expect(row).toHaveAttribute("data-quick-open", "true");
// Press Escape — Radix Dialog default close path.
await page.keyboard.press("Escape");
await expect(surface).toBeHidden();
await expect(row).not.toHaveAttribute("data-quick-open", "true");
});
test("popover only exposes Postpone — Move up/down buttons retired in #489", async ({
page,
}) => {