fix(executive-meetings): quick-actions UX + reschedule/cancel polish (Task #563)

User-reported polish on the executive-meetings quick-actions flow on
iPad/mobile. Six fixes, all client-side:

1. Quick-actions surface now opens from ANY click on a meeting row
   (was: only the attendees cell). Root cause: handleRowClick guard
   skipped any `em-edit-*`, `em-merge-edit-*`, and `em-time-*`
   descendants. Those affordances only do anything in edit mode, but
   handleRowClick already early-returns when edit mode is on, so the
   testid guard was always over-aggressive. Dropped those guards plus
   the `[role='button']` rule from the interactive selector, kept the
   real `button`/`input`/`textarea`/`a`/`menuitem`/`checkbox`/etc.
   guards and the `em-row-actions-*` / `em-row-select-*` testid
   guards (those affordances are visible outside edit mode and must
   not trigger the surface).
   File: artifacts/tx-os/src/pages/executive-meetings.tsx (~L4350).

2. Originating row is now unmistakable on iPad: quickOpenShadow is
   `inset 0 0 0 4px highlight, 0 0 0 2px highlight` (was a single
   3px inset) and the row background tints to
   `rgba(highlight, 0.08)` while quick-actions is open.
   File: artifacts/tx-os/src/pages/executive-meetings.tsx
   (~L4287, L4344).

3. Postpone dialog header was overlapping the Radix close X in RTL.
   Added `pr-8` on the DialogHeader (same pattern already used for
   the row-quick mini-dialog).
   File: artifacts/tx-os/src/components/executive-meetings/
   upcoming-meeting-alert.tsx (~L1447).

4. Reschedule tab: date / start / end inputs collided on iPad
   portrait. Grid is now `grid-cols-1 md:grid-cols-3` (single column
   through iPad portrait, three across on landscape/desktop), with
   `min-w-0` on each cell and `w-full` on every Input so Safari's
   native date/time pickers stop overflowing.
   Same file (~L1761).

5. Cancel tab X overlap is resolved by the same DialogHeader fix in
   step 3 (one Dialog wraps all three tabs).

6. Removed the `cancelHint` paragraph + the `cancelHint` translation
   keys in ar.json (L1252) and en.json (L1164) — fully unused now.

NOT pushed to Gitea (Tailscale to desktop-11cj93j still flaky).
Pending pushes: #560 (VAPID), #561 (CEO roles), #563 (this).
This commit is contained in:
riyadhafraa
2026-05-17 10:18:13 +00:00
parent 5834036707
commit 620ab284e2
4 changed files with 57 additions and 37 deletions
@@ -1436,7 +1436,12 @@ function PostponeDialog({
data-testid="postpone-dialog"
className="sm:max-w-2xl"
>
<DialogHeader className="space-y-1">
{/* #563: reserve pr-8 (~2rem) so the Radix close button
(positioned right-4 top-4 inside DialogContent) never
overlaps the title/description. Physical right padding is
correct for BOTH AR (RTL) and LTR because Radix uses CSS
physical positioning for the close. */}
<DialogHeader className="space-y-1 pr-8">
<DialogTitle className="text-base">
{t("executiveMeetings.alert.postponeTitle")}
</DialogTitle>
@@ -1741,11 +1746,17 @@ function PostponeDialog({
onBack={() => setCascadePrompt(null)}
/>
) : null}
{/* Responsive grid: 1 col on narrow / 2 cols on sm /
3 cols on md+. Prevents horizontal overflow inside the
dialog on phones and split panes. */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
{/* #563: responsive grid — single column on phones AND
iPad portrait (`md` and below), then 3 columns in a
single row on iPad landscape / desktop. The previous
`sm:grid-cols-2` packed date + time-start onto one row
on iPad portrait where Safari's native date/time
pickers carry wide internal icons + Arabic labels and
visibly collided. `min-w-0` lets each column shrink
inside the grid track so inputs respect their cell,
and `w-full` on the Input ensures it fills the cell. */}
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="space-y-1 min-w-0">
<Label className="text-xs">
{t("executiveMeetings.alert.rescheduleDate")}
</Label>
@@ -1755,9 +1766,10 @@ function PostponeDialog({
onChange={(e) => setResDate(e.target.value)}
disabled={busy !== null}
data-testid="reschedule-date"
className="w-full"
/>
</div>
<div className="space-y-1">
<div className="space-y-1 min-w-0">
<Label className="text-xs">
{t("executiveMeetings.alert.rescheduleStart")}
</Label>
@@ -1767,9 +1779,10 @@ function PostponeDialog({
onChange={(e) => setResStart(e.target.value)}
disabled={busy !== null}
data-testid="reschedule-start"
className="w-full"
/>
</div>
<div className="space-y-1">
<div className="space-y-1 min-w-0">
<Label className="text-xs">
{t("executiveMeetings.alert.rescheduleEnd")}
</Label>
@@ -1779,6 +1792,7 @@ function PostponeDialog({
onChange={(e) => setResEnd(e.target.value)}
disabled={busy !== null}
data-testid="reschedule-end"
className="w-full"
/>
</div>
</div>
@@ -1811,9 +1825,9 @@ function PostponeDialog({
className="space-y-2 focus:outline-none"
data-testid="postpone-cancel-panel"
>
<p className="text-xs text-muted-foreground">
{t("executiveMeetings.alert.cancelHint")}
</p>
{/* #563: cancelHint paragraph removed per user request —
the destructive button + confirm prompt convey intent
without the long disclaimer. */}
{!confirmCancel ? (
<Button
type="button"
-1
View File
@@ -1249,7 +1249,6 @@
"rescheduleEnd": "النهاية",
"rescheduleNote": "ملاحظة (اختياري)",
"rescheduleApply": "إعادة الجدولة",
"cancelHint": "يحدد الاجتماع كمُلغى مع الاحتفاظ بسجل الحضور.",
"cancelApply": "إلغاء الاجتماع",
"cancelConfirmPrompt": "هل أنت متأكد؟ سيتم وضع الاجتماع كمُلغى.",
"cancelConfirmYes": "نعم، إلغاء",
-1
View File
@@ -1161,7 +1161,6 @@
"rescheduleEnd": "End",
"rescheduleNote": "Note (optional)",
"rescheduleApply": "Reschedule",
"cancelHint": "Marks the meeting as cancelled. Attendees keep history.",
"cancelApply": "Cancel meeting",
"cancelConfirmPrompt": "Are you sure? The meeting will be marked as cancelled.",
"cancelConfirmYes": "Yes, cancel",
@@ -4275,8 +4275,17 @@ function MeetingRow({
// #496: belt-and-suspenders — also gate on capability so a stale
// `quickOpen=true` from a render before the effect runs cannot
// paint the accented frame in edit mode.
// #563: thicker inset ring + outer glow + tinted background so the
// originating row is unmistakable on iPad where the default 3px
// inset is easy to miss against the table grid. The outer
// `0 0 0 2px` ring sits OUTSIDE the row box (no inset), so the row
// appears framed on all four sides even when adjacent rows would
// otherwise visually clip the inset ring.
const quickOpenShadow = quickActionsCanMutate && quickOpen
? `inset 0 0 0 3px ${highlightColor}`
? `inset 0 0 0 4px ${highlightColor}, 0 0 0 2px ${highlightColor}`
: null;
const quickOpenBg = quickActionsCanMutate && quickOpen
? hexToRgba(highlightColor, 0.08)
: null;
// #499: iPad long-press feedback. dnd-kit's TouchSensor has a 200ms
// activation delay before a long-press becomes a drag — during that
@@ -4329,7 +4338,7 @@ function MeetingRow({
.filter(Boolean)
.join(", ");
const trStyle: CSSProperties = {
backgroundColor: baseBg,
backgroundColor: quickOpenBg ?? baseBg,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
@@ -4340,38 +4349,37 @@ function MeetingRow({
if (!quickActionsCanMutate) return;
const target = e.target as HTMLElement | null;
if (!target) return;
// Skip clicks that landed on an interactive child — leave them to
// their own handlers. Includes ARIA roles because TimeRangeCell
// and a few other affordances render as `div role="button"`
// (keyboard-activatable) rather than native `<button>`s, so a
// pure tag-name filter would let those clicks fall through to
// the row handler and pop the quick-actions menu underneath the
// inline editor the user just opened.
// #489: scope to descendants — the row itself now carries
// role="button" + tabIndex from dnd-kit's `attributes` (because
// dragEnabled is no longer gated by edit mode), so an unscoped
// closest() would match the row itself and swallow every click.
// #563: outside edit mode (which is the only state where
// handleRowClick runs — `quickActionsCanMutate` is force-false
// in edit mode, see line ~2948), the user expects ANY click
// anywhere in the row to open the quick-actions surface, not
// just the attendees cell. We only skip clicks that landed on
// a *truly* interactive native control (buttons, inputs,
// links, menu/checkbox/switch/combobox/dialog roles) — the
// `[role='button']` rule is intentionally DROPPED because
// TimeRangeCell and EditableInlineText render with role='button'
// to mark themselves as keyboard-activatable, but their actual
// edit affordance is gated on `editMode === true` — outside
// edit mode they are display-only and clicks should bubble.
// #489 still holds: scope to descendants so the row's own
// dnd-kit `role="button"` doesn't swallow every click.
const interactive = target.closest(
"button, a, input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='menuitem'], [role='button'], [role='checkbox'], [role='switch'], [role='combobox'], [role='dialog']",
"button, a, input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='menuitem'], [role='checkbox'], [role='switch'], [role='combobox'], [role='dialog']",
);
if (interactive && interactive !== e.currentTarget) {
return;
}
// Skip clicks on row affordances by data-testid prefix so we
// don't fight with drag, row-actions, merge-edit, inline-edit,
// bulk-select, or the time cell's edit-on-click surface
// (em-time-*). The role='button' rule above catches the time
// cell's display state too, but the explicit testid guard keeps
// the intent obvious and survives any future refactor that
// drops the role attribute.
// Keep only the affordances that are visible OUTSIDE edit mode
// and must not trigger quick-actions: the 3-dot row menu and
// the bulk-select checkbox. The previous `em-edit-*`,
// `em-merge-edit-*`, and `em-time-*` guards were dropped because
// those affordances only do anything in edit mode (where this
// handler never fires).
if (
target.closest(
[
`[data-testid="em-row-actions-${meeting.id}"]`,
`[data-testid="em-row-select-${meeting.id}"]`,
`[data-testid^="em-edit-"]`,
`[data-testid^="em-merge-edit-"]`,
`[data-testid^="em-time-"]`,
].join(", "),
)
) {