Files
TX/artifacts/tx-os/src/App.tsx
T
Riyadh 991ddebe90 iOS keyboard fix v3 — visualViewport + touch-gate (#624)
Root causes the prior attempts missed:

1) Safari iOS/iPadOS ignores `interactive-widget=resizes-content`,
   so `100dvh` never shrinks when the soft keyboard opens. Every
   previous override that used `dvh` to size the dialog was sized
   to the full screen and ran behind the keyboard.

2) The CSS rule from #622 (`[role="dialog"]:has(:focus)`) was
   unconditional. On desktop, focusing any input inside a Radix
   DialogContent instantly stretched it to width:100vw + top:0,
   which collided with Radix's open-state slide-animation
   transforms and rendered the dialog as a broken narrow strip
   pinned in the top-left corner — visible in Tahani's laptop
   screenshot when opening "Add Meeting".

3) `AdminFormDialog` puts `role="dialog"` on a NON-fixed inner
   card, so the rule's `top/left/right` had no effect there
   anyway; the card stayed centred in a layout-viewport-sized
   wrapper which iOS does not shrink.

Fix:
- New `useVisualViewportVars` hook mounted in
  NotificationsSocketBridge. Writes `--vv-height` and
  `--vv-offset-top` on `<html>` from `window.visualViewport`,
  updated on resize / scroll / orientationchange. Falls back
  to `innerHeight`/0 if visualViewport is absent.
- Rewrote the index.css dialog rule:
  * Gated inside `@media (any-pointer: coarse)` so desktop is
    completely unaffected.
  * Uses `var(--vv-height, 100vh)` and `var(--vv-offset-top, 0px)`
    instead of `100dvh` / `0`, so dialogs actually track the
    visible band on iOS.
- New `.dialog-vv-wrapper` helper class that applies the same
  vv-sized top/height to bespoke full-screen overlay wrappers.
- Applied `.dialog-vv-wrapper` to AdminFormDialog and the admin
  ConfirmDialog wrappers; switched their `fixed inset-0` to
  `fixed inset-x-0 top-0 h-screen` and their inner cards from
  `max-h-[90vh]`/`max-h-[85vh]` to `max-h-full` so the card
  shrinks with the wrapper.

Kept `useScrollFocusedDialogField` unchanged — works correctly
once the dialog itself is sized to the visible band.

`tsc --noEmit` clean.
2026-05-21 12:04:12 +00:00

106 lines
4.2 KiB
TypeScript

import { Switch, Route, Redirect, Router as WouterRouter, useLocation } from "wouter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AuthProvider } from "@/contexts/AuthContext";
import { IncomingNotePopupProvider } from "@/contexts/IncomingNotePopupContext";
import { IncomingNotePopup } from "@/components/notes/incoming-note-popup";
import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
import { useAutoplayHint } from "@/hooks/use-autoplay-hint";
import { useScrollFocusedDialogField } from "@/hooks/use-scroll-focused-dialog-field";
import { useVisualViewportVars } from "@/hooks/use-visual-viewport-vars";
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
import { PushEnablePrompt } from "@/components/push-enable-prompt";
import NotFound from "@/pages/not-found";
import LoginPage from "@/pages/login";
import RegisterPage from "@/pages/register";
import ForgotPasswordPage from "@/pages/forgot-password";
import ResetPasswordPage from "@/pages/reset-password";
import HomePage from "@/pages/home";
import ServicesPage from "@/pages/services";
import NotificationsPage from "@/pages/notifications";
import AdminPage from "@/pages/admin";
import NotesPage from "@/pages/notes";
import MyOrdersPage from "@/pages/my-orders";
import OrdersIncomingPage from "@/pages/orders-incoming";
import ExecutiveMeetingsPage from "@/pages/executive-meetings";
import EmbeddedAppPage from "@/pages/embedded-app";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 30_000,
},
},
});
function ExecutiveMeetingsRedirect() {
const [location] = useLocation();
const suffix =
typeof window !== "undefined" ? window.location.search + window.location.hash : "";
const subPath = location.replace(/^\/executive-meetings/, "");
return <Redirect to={`/meetings${subPath}${suffix}`} replace />;
}
function NotificationsSocketBridge() {
useNotificationsSocket();
useAudioUnlock();
useAutoplayHint();
useScrollFocusedDialogField();
useVisualViewportVars();
return null;
}
function Router() {
return (
<AuthProvider>
<IncomingNotePopupProvider>
<NotificationsSocketBridge />
<UpcomingMeetingAlert />
<IncomingNotePopup />
<PushEnablePrompt />
<Switch>
<Route path="/login" component={LoginPage} />
<Route path="/register" component={RegisterPage} />
<Route path="/forgot-password" component={ForgotPasswordPage} />
<Route path="/reset-password" component={ResetPasswordPage} />
<Route path="/services" component={ServicesPage} />
<Route path="/notifications" component={NotificationsPage} />
<Route path="/admin" component={AdminPage} />
<Route path="/notes" component={NotesPage} />
<Route path="/my-orders" component={MyOrdersPage} />
<Route path="/orders/incoming" component={OrdersIncomingPage} />
<Route path="/meetings" component={ExecutiveMeetingsPage} />
{/* Back-compat redirect: the page used to live at
/executive-meetings. Stored PDFs, email links, and bookmarks
still point there, so catch the old path (and any deep
sub-path) and forward to /meetings while preserving the
user's query string and hash. */}
<Route path="/executive-meetings" component={ExecutiveMeetingsRedirect} />
<Route path="/executive-meetings/:rest*" component={ExecutiveMeetingsRedirect} />
<Route path="/embedded/:id" component={EmbeddedAppPage} />
<Route path="/" component={HomePage} />
<Route component={NotFound} />
</Switch>
</IncomingNotePopupProvider>
</AuthProvider>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
<Router />
</WouterRouter>
<Toaster />
</TooltipProvider>
</QueryClientProvider>
);
}
export default App;