Task #499: fix Quick Actions dialog X overlap + iPad long-press drag feedback
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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user