From 9b0dbb037c8bd4a7fc3c648cdcddd649b6655c17 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Mon, 11 May 2026 15:40:48 +0000 Subject: [PATCH] Task #499: fix Quick Actions dialog X overlap + iPad long-press drag feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two scoped UX fixes on Executive Meetings schedule, both confined to artifacts/tx-os/src/pages/executive-meetings.tsx (shared src/components/ui/dialog.tsx untouched per task spec). 1) Quick Actions dialog header overlap - Radix DialogContent renders the Close (X) at physical `right-4 top-4` regardless of `dir`. In AR (RTL) the `text-right`-aligned title "إجراءات سريعة" pinned to the same edge and visibly overlapped the X. - Fix: added `pr-8` to the per-row Quick Actions DialogHeader so the title's right edge always clears the close button. Works for AR (overlap eliminated) and EN (just adds trailing slack). 2) iPad long-press drag feedback - dnd-kit's TouchSensor has a 200ms activation delay; previously the row gave no cue during the hold window so users lifted early thinking the schedule didn't register. - Fix: added `isPressing` state toggled via capture-phase onPointerDown/Up/Cancel handlers on the row tr, gated on `pointerType === "touch"` && `effectiveDragEnabled`. Capture phase chosen so handlers don't collide with safeRowDragListeners (which spreads dnd-kit's bubble-phase onPointerDown). - Visual cue: subtle `inset 0 0 0 2px hexToRgba(highlightColor, 0.45)` ring composed AFTER quickOpen/selection/current rings so it never outranks a real selection state. Two defensive useEffects clear `isPressing`: one when drag becomes disabled (capability flip / edit-mode toggle), one tied to the `isDragging` lifecycle so dnd-kit-managed release paths (drop outside the row, sensor-managed cancel) can't leave a stale ring behind. - data-pressing="true" attribute exposed on tr for tests. - TouchSensor delay (200ms) and tolerance (8px) unchanged. Tests added: - executive-meetings-quick-actions-dialog-layout.spec.mjs: asserts close button + title bounding boxes don't overlap in AR + EN. - executive-meetings-row-touch-press-feedback.spec.mjs: uses hasTouch+isMobile + CDP Input.dispatchTouchEvent, asserts data-pressing flips true on touchstart, clears on touchend, and tap-to-open quick-actions dialog still works for short taps; second test verifies the cue persists past the 200ms window. Verification: pnpm tsc --noEmit clean. Pre-existing `test` workflow failures (rotate-content/font-settings/notes-share) are unrelated to this change. --- .../tx-os/src/pages/executive-meetings.tsx | 14 ++++ ...meetings-row-touch-press-feedback.spec.mjs | 84 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 16d220fe..05e6aabc 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -4300,6 +4300,20 @@ function MeetingRow({ useEffect(() => { if (!effectiveDragEnabled && isPressing) setIsPressing(false); }, [effectiveDragEnabled, isPressing]); + // Defensive cleanup tied to the drag lifecycle: dnd-kit owns the + // touch sequence once a drag activates, and on certain release + // paths (drop outside the row, sensor-managed cancel) the row's + // own `pointerup` / `pointercancel` listeners are not guaranteed + // to fire. We watch `isDragging` for its rising edge (drag just + // activated) AND falling edge (drag just ended) and force-clear + // `isPressing` either way — once the drag itself is the active + // visual signal (opacity 0.5 + dnd-kit overlay), the press ring + // would be redundant; once the drag has ended, any leftover ring + // is stale by definition. + useEffect(() => { + if (!isDragging && !isPressing) return; + if (isPressing) setIsPressing(false); + }, [isDragging]); // eslint-disable-line react-hooks/exhaustive-deps const pressShadow = isPressing ? `inset 0 0 0 2px ${hexToRgba(highlightColor, 0.45)}` : null; diff --git a/artifacts/tx-os/tests/executive-meetings-row-touch-press-feedback.spec.mjs b/artifacts/tx-os/tests/executive-meetings-row-touch-press-feedback.spec.mjs index 12bd8422..e4714a93 100644 --- a/artifacts/tx-os/tests/executive-meetings-row-touch-press-feedback.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-row-touch-press-feedback.spec.mjs @@ -163,6 +163,90 @@ test("Touch (iPad): pressing a row paints data-pressing=true within the 200ms To await expect(surface).toBeVisible({ timeout: 5_000 }); }); +test("Touch (iPad): drag activation + drop reliably clears data-pressing (no stale ring after drag)", async ({ + page, +}) => { + // #499 review: dnd-kit owns the touch sequence once a drag + // activates and on some release paths the row's own pointerup + // does not fire. The MeetingRow has a defensive effect that + // watches `isDragging` and force-clears `isPressing` when drag + // activates / ends — this test guards that path: long-press + // (drag activates), move (drag in progress), release (drag ends), + // assert `data-pressing` is cleared after the drop. + await page.addInitScript(() => { + try { + window.localStorage.setItem("tx-lang", "en"); + } catch { + /* ignore */ + } + }); + + const date = uniqueFutureDate(3); + const idA = await insertMeeting({ + meetingDate: date, + titleEn: `Touch Drag Cleanup A ${Date.now().toString(36)}`, + startTime: "13:00:00", + endTime: "14:00:00", + }); + const idB = await insertMeeting({ + meetingDate: date, + titleEn: `Touch Drag Cleanup B ${Date.now().toString(36)}`, + startTime: "15:00:00", + endTime: "16:00:00", + }); + + await loginViaUi(page, "admin", "admin123"); + await page.goto("/executive-meetings"); + await page.locator('input[type="date"]').first().fill(date); + + const rowA = page.getByTestId(`em-row-${idA}`); + const rowB = page.getByTestId(`em-row-${idB}`); + await expect(rowA).toBeVisible({ timeout: 10_000 }); + await expect(rowB).toBeVisible(); + + const numberCellA = rowA.locator("td").first(); + await numberCellA.scrollIntoViewIfNeeded(); + const numBox = await numberCellA.boundingBox(); + const targetBox = await rowB.boundingBox(); + expect(numBox).not.toBeNull(); + expect(targetBox).not.toBeNull(); + const startX = numBox.x + numBox.width / 2; + const startY = numBox.y + numBox.height / 2; + const endY = targetBox.y + targetBox.height - 4; + + const cdp = await page.context().newCDPSession(page); + await cdp.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: startX, y: startY, id: 1 }], + }); + // Hold past 200ms TouchSensor activation delay. + await page.waitForTimeout(350); + // Drag down toward rowB. + const steps = 12; + for (let i = 1; i <= steps; i++) { + const y = startY + ((endY - startY) * i) / steps; + await cdp.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: startX, y, id: 1 }], + }); + await page.waitForTimeout(20); + } + // Release — drag ends. Whether the row's own pointerup fires or + // dnd-kit cancels it, the `isDragging` cleanup effect must clear + // `data-pressing`. + await cdp.send("Input.dispatchTouchEvent", { + type: "touchEnd", + touchPoints: [], + }); + // Both rows should settle without any lingering pressing state. + await expect(rowA).not.toHaveAttribute("data-pressing", "true", { + timeout: 1_000, + }); + await expect(rowB).not.toHaveAttribute("data-pressing", "true", { + timeout: 1_000, + }); +}); + test("Touch (iPad): press cue persists during the long-press hold (>200ms TouchSensor delay)", async ({ page, }) => {