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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user