diff --git a/artifacts/tx-os/src/components/ui/dialog.tsx b/artifacts/tx-os/src/components/ui/dialog.tsx index f82e4e15..e1e6c5bd 100644 --- a/artifacts/tx-os/src/components/ui/dialog.tsx +++ b/artifacts/tx-os/src/components/ui/dialog.tsx @@ -39,6 +39,19 @@ const DialogContent = React.forwardRef< // changes are ignored so desktop and normal mobile scrolling stay // untouched. const keyboardOpen = keyboardInset > 80 + // CSS vars consumed by the global `[data-dialog-keyboard-open="true"]` + // rule in index.css. We pass position/size through CSS variables and + // let `!important` rules win the cascade against arbitrary consumer + // Tailwind classes (`w-[calc(100vw-1rem)]`, `max-w-3xl`, `max-h-[92vh]`, + // entrance-animation transforms, etc.) which previously beat inline + // `style` on iPad PWA and left the sheet pinned as a narrow strip in + // the top-left corner. + const kbVars = keyboardOpen + ? ({ + ["--dialog-top" as never]: `${offsetTop}px`, + ["--dialog-max-h" as never]: `${height - 8}px`, + } as React.CSSProperties) + : undefined const innerRef = React.useRef(null) // Merge the consumer-provided forwardRef with our own ref so we can // attach focus listeners without breaking Radix's portal/animation @@ -109,64 +122,14 @@ const DialogContent = React.forwardRef< node.addEventListener("focusin", onFocusIn) return () => node.removeEventListener("focusin", onFocusIn) }, []) - const dynamicStyle: React.CSSProperties = keyboardOpen - ? { - // Mobile-sheet behaviour when an on-screen keyboard is open. - // - // The base className puts the dialog at left:50%+translateX(-50%) - // (horizontal centering) and top:50%+translateY(-50%) (vertical - // centering). On iOS Safari with the soft keyboard open the - // layout viewport and the visual viewport diverge in BOTH axes - // (visualViewport.offsetLeft, .pageLeft and zoom factor can all - // shift), which made our previous "top-anchor + translateX(-50%)" - // override render as a narrow column shoved against one edge of - // the screen. - // - // To sidestep all of that we drop the centering entirely while - // the keyboard is open and pin the dialog as a top-anchored - // full-width sheet: explicit left/right insets, no transform, - // width:auto so the consumer's `w-[calc(100vw-1rem)]` or - // `max-w-3xl` no longer drive horizontal position. The - // dialog's internal `flex flex-col` (header / scrollable middle - // / sticky footer) then keeps Save/Cancel pinned above the - // keyboard and the rest of the form scrolls inside. - // `position: fixed` on iOS is anchored to the LAYOUT viewport, - // which stays at `window.innerWidth` even while the visual - // viewport shrinks (keyboard, pinch-zoom, etc). If we set - // width/left from `visualViewport.width/offsetLeft` the dialog - // ends up rendered at a fraction of the screen — a narrow - // strip pinned to one corner — because those values describe - // the *visible* rectangle in layout pixels, not the dialog's - // own container. - // - // Pin horizontally to the layout viewport (0 / 100vw) so the - // dialog always spans the full width the iframe sees, and pin - // vertically to the visual viewport so it sits just above the - // keyboard. `maxHeight` uses `vv.height` so the dialog's - // internal scroll keeps the footer above the keyboard. - top: `${offsetTop}px`, - left: 0, - right: 0, - width: "100vw", - maxWidth: "none", - maxHeight: `${height - 8}px`, - transform: "none", - // Make the dialog itself the scrollable region above the - // keyboard so scrollIntoView() on the focused field actually - // moves it into view (its nearest scrollable ancestor is now - // the dialog, not the body which can't scroll while a - // position:fixed parent sits on top). - overflowY: "auto", - WebkitOverflowScrolling: "touch", - ...style, - } - : (style ?? {}) + const mergedStyle: React.CSSProperties = { ...(kbVars ?? {}), ...(style ?? {}) } return (