editable-cell: keep formatting toolbar above the iPad keyboard (#581)

Bug: on iPad / iOS Safari, tapping a meetings-table editable cell hid
the floating formatting toolbar (color swatches, alignment, font, save,
cancel) under the soft keyboard. Users saw only Apple's tiny B/I/U
accessory bar and could not change color or even tap Save/Cancel.

Root cause in `artifacts/tx-os/src/components/editable-cell.tsx`:
FormattingToolbar positioned itself at `rect.bottom + 4` and explicitly
refused to flip above the cell. It also only listened to window scroll
and resize, not to `visualViewport` events, so it did not react to the
keyboard opening or closing.

Fix (single file):
- Import and call the existing `useVisualViewport` hook inside
  FormattingToolbar to get reactive `{ height, offsetTop }`.
- Compute the visible viewport bottom as `offsetTop + height` and, when
  the toolbar would not fit below the cell without going under that
  bottom (keyboard or short viewport), flip it ABOVE the cell:
  `top = max(vv.offsetTop + 4, rect.top - tbHeight - 4)`.
- Keep desktop behavior unchanged: when the viewport is full-height the
  below position still fits, so we still place below the cell.
- Added `vv.height`, `vv.offsetTop`, `vv.keyboardInset` to the effect
  deps so the toolbar tracks the keyboard show/hide animation.
- Replaced the "never flip above" comment block with a comment that
  documents the keyboard-coverage exception.

Out of scope (unchanged): toolbar wrapping/overflow on very narrow
iPads, Apple's built-in B/I/U keyboard accessory bar, any other page.

Validation:
- `pnpm --filter @workspace/tx-os exec tsc --noEmit` clean.
- Vite HMR updated editable-cell.tsx without errors.
This commit is contained in:
Riyadh
2026-05-18 08:29:22 +00:00
parent 743309ca36
commit 53d637aa35
@@ -15,6 +15,7 @@ import { Color } from "@tiptap/extension-color";
import { FontFamily } from "@tiptap/extension-font-family";
import { TextAlign } from "@tiptap/extension-text-align";
import { safeHtml } from "@/lib/safe-html";
import { useVisualViewport } from "@/hooks/use-visual-viewport";
import {
Bold as BoldIcon,
Italic as ItalicIcon,
@@ -557,6 +558,14 @@ function FormattingToolbar({
[toolbarRef],
);
// Track the visible viewport so the toolbar can dodge the iOS soft
// keyboard. On iPad / iOS Safari, when the keyboard opens it overlays
// the bottom of the layout viewport — `visualViewport.height` shrinks
// accordingly. We use that to decide when to flip the toolbar above
// the cell instead of below, so the color swatches / Save / Cancel
// stay reachable instead of being hidden under the keyboard.
const vv = useVisualViewport();
useLayoutEffect(() => {
// Walk up to find the nearest horizontally-scrolling ancestor
// (the table's `overflow-x-auto` wrapper). The toolbar must stay
@@ -578,11 +587,20 @@ function FormattingToolbar({
const rect = anchor.getBoundingClientRect();
const tbHeight = toolbarEl?.offsetHeight ?? 36;
const tbWidth = toolbarEl?.offsetWidth ?? 320;
// Always place BELOW the editing cell. The task spec requires the
// toolbar to never sit on top of the row above the active cell, so
// we deliberately do NOT flip above even when there isn't room
// below — the page can scroll instead.
const top = rect.bottom + 4;
// Prefer placing the toolbar BELOW the editing cell (keeps it
// from sitting on top of the row above the active cell, which is
// what the schedule spec wants). We only flip ABOVE when staying
// below would put the toolbar under the iOS soft keyboard or off
// the bottom of the visible viewport — otherwise the color
// swatches / Save / Cancel become unreachable on iPad. See #581.
const vvTop = vv.offsetTop;
const vvBottom =
vv.offsetTop + (vv.height || window.innerHeight);
let top = rect.bottom + 4;
if (top + tbHeight > vvBottom - 4) {
const above = rect.top - tbHeight - 4;
top = Math.max(vvTop + 4, above);
}
// Clamp horizontally to the table's scroll container if we can
// find one; fall back to the viewport otherwise. This keeps the
// toolbar visually anchored inside the table.
@@ -613,7 +631,7 @@ function FormattingToolbar({
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [anchorRef, toolbarEl]);
}, [anchorRef, toolbarEl, vv.height, vv.offsetTop, vv.keyboardInset]);
const node = (
<div