From 178ac7a23c19a01fbbb924e4470ea8ac00545d90 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 21 Jul 2025 19:54:23 +0200 Subject: [PATCH 01/37] Add favicon and apple-touch-icon routes to AppGateway for proper icon serving --- application/AppGateway/appsettings.json | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/application/AppGateway/appsettings.json b/application/AppGateway/appsettings.json index 7595556c9..c1f5ca5e9 100644 --- a/application/AppGateway/appsettings.json +++ b/application/AppGateway/appsettings.json @@ -11,6 +11,34 @@ "AllowedHosts": "*", "ReverseProxy": { "Routes": { + "favicon": { + "ClusterId": "account-management-static", + "Match": { + "Path": "/favicon.ico" + }, + "Transforms": [ + { + "ResponseHeader": "Cache-Control", + "Set": "public, max-age=604800" + }, + { + "ResponseHeader": "Content-Type", + "Set": "image/x-icon" + } + ] + }, + "apple-touch-icon": { + "ClusterId": "account-management-static", + "Match": { + "Path": "/apple-touch-icon.png" + }, + "Transforms": [ + { + "ResponseHeader": "Cache-Control", + "Set": "public, max-age=604800" + } + ] + }, "root": { "ClusterId": "account-management-api", "Match": { From c0b56da783cbb4b0207f342671c85b2d4753dabc Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 22 Jul 2025 20:21:47 +0200 Subject: [PATCH 02/37] Fix keyboard navigation and focus management for UserTable on mobile screens --- .../users/-components/UserProfileSidePane.tsx | 48 ++++++++-- .../admin/users/-components/UserTable.tsx | 87 ++++++++++++++++--- .../WebApp/routes/admin/users/index.tsx | 14 ++- 3 files changed, 129 insertions(+), 20 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 323825fed..e1052b1c3 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -135,17 +135,40 @@ function useSidePaneAccessibility( sidePaneRef: React.RefObject, closeButtonRef: React.RefObject ) { + const previouslyFocusedElement = useRef(null); + useEffect(() => { - const isMobileScreen = !window.matchMedia(MEDIA_QUERIES.sm).matches; - if (isOpen && closeButtonRef.current && isMobileScreen) { + const isSmallScreen = !window.matchMedia(MEDIA_QUERIES.md).matches; + if (isOpen && closeButtonRef.current && isSmallScreen) { + // Store the currently focused element before moving focus + previouslyFocusedElement.current = document.activeElement as HTMLElement; closeButtonRef.current.focus(); } }, [isOpen, closeButtonRef]); + // Prevent body scroll on small screens when side pane is open + useEffect(() => { + const isSmallScreen = !window.matchMedia(MEDIA_QUERIES.md).matches; + if (isOpen && isSmallScreen) { + const originalStyle = window.getComputedStyle(document.body).overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = originalStyle; + }; + } + }, [isOpen]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && isOpen) { event.preventDefault(); + const isSmallScreen = !window.matchMedia(MEDIA_QUERIES.md).matches; + + // Restore focus on small screens before closing + if (isSmallScreen && previouslyFocusedElement.current) { + previouslyFocusedElement.current.focus(); + } + onClose(); } }; @@ -160,8 +183,8 @@ function useSidePaneAccessibility( }, [isOpen, onClose]); useEffect(() => { - const isMobileScreen = !window.matchMedia(MEDIA_QUERIES.sm).matches; - if (!isOpen || !sidePaneRef.current || !isMobileScreen) { + const isSmallScreen = !window.matchMedia(MEDIA_QUERIES.md).matches; + if (!isOpen || !sidePaneRef.current || !isSmallScreen) { return; } @@ -206,6 +229,18 @@ export function UserProfileSidePane({ const sidePaneRef = useRef(null); const closeButtonRef = useRef(null); const [isChangeRoleDialogOpen, setIsChangeRoleDialogOpen] = useState(false); + const [isSmallScreen, setIsSmallScreen] = useState(false); + + // Check screen size for backdrop rendering + useEffect(() => { + const checkScreenSize = () => { + setIsSmallScreen(!window.matchMedia(MEDIA_QUERIES.md).matches); + }; + + checkScreenSize(); + window.addEventListener("resize", checkScreenSize); + return () => window.removeEventListener("resize", checkScreenSize); + }, []); useSidePaneAccessibility(isOpen, onClose, sidePaneRef, closeButtonRef); @@ -218,10 +253,13 @@ export function UserProfileSidePane({ return ( <> + {/* Backdrop for small screens */} + {isSmallScreen && From 6cb06ad8256db381168c44b4bfd18c0e9184cabe Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 25 Jul 2025 02:08:02 +0200 Subject: [PATCH 09/37] Make toast messages full width on mobile screens --- application/shared-webapp/ui/components/Toast.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/shared-webapp/ui/components/Toast.tsx b/application/shared-webapp/ui/components/Toast.tsx index f89e3d223..3f20847af 100644 --- a/application/shared-webapp/ui/components/Toast.tsx +++ b/application/shared-webapp/ui/components/Toast.tsx @@ -135,7 +135,7 @@ function ToastRegion({ state, ...props }: Readonly {state.visibleToasts.map((toast) => ( @@ -145,7 +145,7 @@ function ToastRegion({ state, ...props }: Readonly Date: Fri, 25 Jul 2025 02:14:20 +0200 Subject: [PATCH 10/37] Make pagination sticky at bottom on mobile devices --- .../WebApp/routes/admin/users/-components/UserTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx index 1641bdb12..dd37dd76f 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -321,7 +321,7 @@ export function UserTable({ {users && ( -
+
Date: Fri, 25 Jul 2025 02:23:41 +0200 Subject: [PATCH 11/37] Add PWA support and native mobile app feel --- application/AppGateway/appsettings.json | 16 ++++++++++++ .../WebApp/public/index.html | 7 ++++- .../WebApp/public/manifest.json | 22 ++++++++++++++++ .../back-office/WebApp/public/index.html | 8 +++++- application/shared-webapp/ui/tailwind.css | 26 +++++++++++++++++++ 5 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 application/account-management/WebApp/public/manifest.json diff --git a/application/AppGateway/appsettings.json b/application/AppGateway/appsettings.json index c1f5ca5e9..bc3966aca 100644 --- a/application/AppGateway/appsettings.json +++ b/application/AppGateway/appsettings.json @@ -39,6 +39,22 @@ } ] }, + "manifest": { + "ClusterId": "account-management-static", + "Match": { + "Path": "/manifest.json" + }, + "Transforms": [ + { + "ResponseHeader": "Cache-Control", + "Set": "public, max-age=604800" + }, + { + "ResponseHeader": "Content-Type", + "Set": "application/manifest+json" + } + ] + }, "root": { "ClusterId": "account-management-api", "Match": { diff --git a/application/account-management/WebApp/public/index.html b/application/account-management/WebApp/public/index.html index 6548a3dfb..f0d4fc36e 100644 --- a/application/account-management/WebApp/public/index.html +++ b/application/account-management/WebApp/public/index.html @@ -3,10 +3,15 @@ - + + + + + PlatformPlatform +
diff --git a/application/account-management/WebApp/public/manifest.json b/application/account-management/WebApp/public/manifest.json new file mode 100644 index 000000000..4d8248635 --- /dev/null +++ b/application/account-management/WebApp/public/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "PlatformPlatform", + "short_name": "PlatformPlatform", + "description": "PlatformPlatform Application", + "start_url": "/", + "display": "standalone", + "orientation": "portrait", + "theme_color": "#000000", + "background_color": "#ffffff", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + } + ] +} diff --git a/application/back-office/WebApp/public/index.html b/application/back-office/WebApp/public/index.html index bba1186a5..03b86ba28 100644 --- a/application/back-office/WebApp/public/index.html +++ b/application/back-office/WebApp/public/index.html @@ -3,10 +3,16 @@ - + + + + + + PlatformPlatform - Back Office PlatformPlatform - Back Office +
diff --git a/application/shared-webapp/ui/tailwind.css b/application/shared-webapp/ui/tailwind.css index c1494dd10..a5538d557 100644 --- a/application/shared-webapp/ui/tailwind.css +++ b/application/shared-webapp/ui/tailwind.css @@ -158,8 +158,34 @@ body, padding: 0; margin: 0; overflow: hidden; + /* Prevent bounce scrolling on iOS */ + position: fixed; + overscroll-behavior: none; + -webkit-overflow-scrolling: touch; } #root { overflow: auto; + /* Ensure full height on iOS */ + height: 100vh; + height: -webkit-fill-available; +} + +/* Prevent text selection on UI elements for app-like feel */ +@media (max-width: 640px) { + button, + [role="button"], + .menu-item, + label { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } + + /* Improve tap responsiveness */ + a, + button, + [role="button"] { + -webkit-tap-highlight-color: transparent; + } } From 9135ee1d049069304187781da25a98aa065d83d7 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 25 Jul 2025 10:07:44 +0200 Subject: [PATCH 12/37] Make hamburger menu button fixed position on mobile --- application/shared-webapp/ui/components/SideMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index f62b7fa7b..7134e034c 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -982,10 +982,10 @@ function MobileMenu({ ariaLabel, topMenuContent }: { ariaLabel: string; topMenuC return ( <> {!isOpen && ( -
+
+ + onViewProfile(user, false)}> + + View profile + + {userInfo?.role === "Owner" && ( + <> + onChangeRole(user)} + > + + Change role + + + onDeleteUser(user)} + > + + + Delete + + + + )} + + + + + + ))} + + {users && ( -
+
)} -
+ ); } diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index cd2f6f731..a8849f39f 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -141,7 +141,7 @@ export default function UsersPage() { } > -
+

Users

@@ -152,16 +152,14 @@ export default function UsersPage() {
-
- -
+
diff --git a/application/shared-webapp/ui/components/AppLayout.tsx b/application/shared-webapp/ui/components/AppLayout.tsx index a7c42e59a..c55083c3f 100644 --- a/application/shared-webapp/ui/components/AppLayout.tsx +++ b/application/shared-webapp/ui/components/AppLayout.tsx @@ -10,6 +10,7 @@ type AppLayoutProps = { variant?: AppLayoutVariant; maxWidth?: string; sidePane?: React.ReactNode; + paddingBottom?: string; }; /** @@ -52,7 +53,7 @@ export function AppLayout({ }, [isOverlayOpen]); return ( - <> + ); } diff --git a/application/shared-webapp/ui/tailwind.css b/application/shared-webapp/ui/tailwind.css index 2a1480d1e..48ec4db48 100644 --- a/application/shared-webapp/ui/tailwind.css +++ b/application/shared-webapp/ui/tailwind.css @@ -150,9 +150,14 @@ } } -html, -body, -#root { +html { + height: 100%; + width: 100%; + padding: 0; + margin: 0; +} + +body { height: 100%; width: 100%; padding: 0; @@ -165,10 +170,14 @@ body, } #root { - overflow: auto; - /* Ensure full height on iOS */ - height: 100vh; - height: -webkit-fill-available; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + display: flex; + flex-direction: column; } /* Prevent text selection on UI elements for app-like feel */ From 228cf08a92cf9cc466a6799c38e911fe6b45cc33 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 25 Jul 2025 19:36:11 +0200 Subject: [PATCH 15/37] Fix mobile UI spacing and safe area handling for iOS devices --- .../federated-modules/sideMenu/MobileMenu.tsx | 2 +- .../admin/users/-components/UserTable.tsx | 2 +- .../shared-webapp/ui/components/AppLayout.tsx | 2 +- .../ui/components/DialogFooter.tsx | 2 ++ .../shared-webapp/ui/components/SideMenu.tsx | 24 ++++++++++++------- 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx b/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx index 5724d0b66..bf8713e0d 100644 --- a/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx +++ b/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx @@ -150,7 +150,7 @@ export function MobileMenu({
{/* Navigation Section for Mobile */} -
+
Navigation diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx index 963797ba8..82acad04a 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -325,7 +325,7 @@ export function UserTable({ currentPage={currentPage} totalPages={users.totalPages ?? 1} onPageChange={handlePageChange} - className="w-full pr-12 sm:hidden" + className="w-full pr-20 sm:hidden" /> @@ -41,6 +42,7 @@ export function DialogContent({ children, className }: Readonly<{ children: Reac className={twMerge( "min-h-0 flex-1 overflow-y-auto overflow-x-hidden", "max-sm:pb-20", // Add padding to account for fixed footer on mobile + "max-sm:supports-[padding:max(0px)]:pb-[calc(5rem+env(safe-area-inset-bottom))]", // Adjust for safe area "-webkit-overflow-scrolling-touch", "max-sm:-mx-6 max-sm:px-6", // Extend scrollbar to edge while maintaining content padding className diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 7134e034c..ddac881e1 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -982,10 +982,10 @@ function MobileMenu({ ariaLabel, topMenuContent }: { ariaLabel: string; topMenuC return ( <> {!isOpen && ( -
+
From 0e90cc76f101d9adc6affc8e30906e97cf50cf66 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 25 Jul 2025 21:13:23 +0200 Subject: [PATCH 16/37] Fix safe area padding for iPad devices in UserProfileSidePane --- .../routes/admin/users/-components/UserProfileSidePane.tsx | 2 +- application/shared-webapp/ui/components/AppLayout.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 9a9dad309..c838012b7 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -326,7 +326,7 @@ export function UserProfileSidePane({ {/* Quick Actions */} {userInfo?.role === "Owner" && user && ( -
+
- - onViewProfile(user, false)}> - - View profile - - {userInfo?.role === "Owner" && ( - <> - onChangeRole(user)} - > - - Change role - - - onDeleteUser(user)} - > - - - Delete - - - - )} - - - +
+
+ +
+
+ + {user.firstName} {user.lastName} + + {user.emailConfirmed ? null : ( + + Pending + + )} +
+ {user.title ?? ""} +
+
+
+ {isMinSm && ( + + {user.email} + + )} + {isMinMd && ( + + + {formatDate(user.createdAt)} + + + )} + {isMinMd && ( + + + {formatDate(user.modifiedAt)} + + + )} + {isMinSm && ( + +
+ {getUserRoleLabel(user.role)} + { + if (isOpen) { + onSelectedUsersChange([user]); + } + }} + > + + + onViewProfile(user, false)}> + + View profile + + {userInfo?.role === "Owner" && ( + <> + onChangeRole(user)} + > + + Change role + + + onDeleteUser(user)} + > + + + Delete + + + + )} + + +
+
+ )} ))} diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 343d4c44b..66ce31610 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -31,9 +31,6 @@ msgstr "Kontoindstillinger" msgid "Account updated successfully" msgstr "Konto opdateret succesfuldt" -msgid "Actions" -msgstr "Handlinger" - msgid "Active" msgstr "Aktiv" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 5cd3ed62b..9a36de3ef 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -31,9 +31,6 @@ msgstr "Account settings" msgid "Account updated successfully" msgstr "Account updated successfully" -msgid "Actions" -msgstr "Actions" - msgid "Active" msgstr "Active" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 7bc27d81d..31b65b0a2 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -31,9 +31,6 @@ msgstr "Accountinstellingen" msgid "Account updated successfully" msgstr "Account succesvol bijgewerkt" -msgid "Actions" -msgstr "Acties" - msgid "Active" msgstr "Actief" From 7b21309d6814469663e82ebf631180953ee9199f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 25 Jul 2025 23:27:48 +0200 Subject: [PATCH 22/37] Remove autofocus from admin input fields to prevent mobile keyboards --- .../WebApp/federated-modules/common/UserProfileModal.tsx | 1 - .../account-management/WebApp/routes/admin/account/index.tsx | 1 - .../WebApp/routes/admin/users/-components/UserQuerying.tsx | 1 - 3 files changed, 3 deletions(-) diff --git a/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx b/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx index df2816a20..2e132c6af 100644 --- a/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx +++ b/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx @@ -208,7 +208,6 @@ export default function UserProfileModal({ isOpen, onOpenChange }: Readonly From b3df044b8baf862676f970716b8cda0b4591a934 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 25 Jul 2025 23:41:44 +0200 Subject: [PATCH 23/37] Fix double-tap navigation issue on iPad by using React Aria Link component --- .../shared-webapp/ui/components/SideMenu.tsx | 58 +++++++++---------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index ddac881e1..cad9c51ad 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -317,31 +317,6 @@ export function FederatedMenuButton({ const linkClassName = menuButtonStyles({ isCollapsed, isActive, isDisabled }); - const handleClick = (e: React.MouseEvent) => { - if (isDisabled) { - e.preventDefault(); - return; - } - - // Auto-close overlay after navigation - if (overlayCtx?.isOpen) { - overlayCtx.close(); - } - - // Always prevent default to handle navigation ourselves - e.preventDefault(); - - if (isCurrentSystem) { - // Same system - use programmatic navigation - window.history.pushState({}, "", to); - // Dispatch a popstate event using the standard Event constructor - window.dispatchEvent(new Event("popstate")); - } else { - // Different system - force reload - window.location.href = to; - } - }; - // For collapsed menu, wrap in TooltipTrigger if (isCollapsed) { return ( @@ -386,19 +361,40 @@ export function FederatedMenuButton({ ); } - // For expanded menu, use a regular anchor tag with onClick handler + // For expanded menu, use React Aria Link for consistent touch handling return (
); } From 79940a21aafe3acedefb104f836aec057f736006 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 26 Jul 2025 00:25:57 +0200 Subject: [PATCH 24/37] Add touch support for side menu resizing on iPad --- .../shared-webapp/ui/components/SideMenu.tsx | 773 ++++++++++++------ 1 file changed, 510 insertions(+), 263 deletions(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index cad9c51ad..bca1462c3 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -471,34 +471,6 @@ const _getUserPreference = (): boolean => { return localStorage.getItem("side-menu-collapsed") === "true"; }; -// Helper function to check if drag movement has started -const _checkDragStarted = ( - e: MouseEvent, - dragStartPos: React.MutableRefObject<{ x: number; y: number } | null>, - hasDraggedRef: React.MutableRefObject -): void => { - if (dragStartPos.current && !hasDraggedRef.current) { - const distance = Math.abs(e.clientX - dragStartPos.current.x) + Math.abs(e.clientY - dragStartPos.current.y); - if (distance > 5) { - hasDraggedRef.current = true; - } - } -}; - -// Helper function to handle resize actions -const _handleResizeAction = ( - mouseX: number, - isCollapsed: boolean, - hasTriggeredCollapse: boolean, - setMenuWidth: (width: number) => void -): void => { - if (!isCollapsed && !hasTriggeredCollapse) { - const newWidth = Math.min(Math.max(mouseX, SIDE_MENU_MIN_WIDTH), SIDE_MENU_MAX_WIDTH); - setMenuWidth(newWidth); - window.dispatchEvent(new CustomEvent("side-menu-resize", { detail: { width: newWidth } })); - } -}; - // Helper function to dispatch menu toggle event const _dispatchMenuToggleEvent = (overlayMode: boolean, isExpanded: boolean): void => { if (overlayMode) { @@ -508,6 +480,214 @@ const _dispatchMenuToggleEvent = (overlayMode: boolean, isExpanded: boolean): vo } }; +// Custom hook for overlay behavior +function useOverlayHandlers({ + overlayMode, + isOverlayOpen, + closeOverlay, + sideMenuRef +}: { + overlayMode: boolean; + isOverlayOpen: boolean; + closeOverlay: () => void; + sideMenuRef: React.RefObject; +}) { + // Handle click outside for overlay + useEffect(() => { + if (!overlayMode || !isOverlayOpen) { + return; + } + + const handleClickOutside = (event: MouseEvent) => { + if (sideMenuRef.current && !sideMenuRef.current.contains(event.target as Node)) { + closeOverlay(); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closeOverlay(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [overlayMode, isOverlayOpen, closeOverlay, sideMenuRef]); + + // Close mobile menu on resize + useEffect(() => { + const handleResize = () => { + const isNowMedium = window.matchMedia(MEDIA_QUERIES.sm).matches; + if (isNowMedium && isOverlayOpen) { + closeOverlay(); + } + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [isOverlayOpen, closeOverlay]); + + // Focus trap for overlay + useEffect(() => { + if (!isOverlayOpen || !overlayMode) { + return; + } + + const handleKeyDown = (e: KeyboardEvent) => { + _handleFocusTrap(e, sideMenuRef); + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOverlayOpen, overlayMode, sideMenuRef]); +} + +// Helper to handle resize movement +function createResizeHandler(params: { + isCollapsed: boolean; + dragStartPos: React.MutableRefObject<{ x: number; y: number } | null>; + hasDraggedRef: React.MutableRefObject; + initialMenuWidth: React.MutableRefObject; + toggleMenu: () => void; + setIsResizing: (value: boolean) => void; + setMenuWidth: (value: number) => void; +}) { + let hasTriggeredCollapse = false; + + const checkDragStarted = (clientX: number, clientY: number) => { + if (!params.dragStartPos.current) { + return false; + } + const distance = + Math.abs(clientX - params.dragStartPos.current.x) + Math.abs(clientY - params.dragStartPos.current.y); + return distance > 5; + }; + + const handleResize = (newWidth: number) => { + const clampedWidth = Math.min(Math.max(newWidth, SIDE_MENU_MIN_WIDTH), SIDE_MENU_MAX_WIDTH); + params.setMenuWidth(clampedWidth); + window.dispatchEvent(new CustomEvent("side-menu-resize", { detail: { width: clampedWidth } })); + }; + + const handleToggle = () => { + if (!params.isCollapsed) { + hasTriggeredCollapse = true; + } + params.toggleMenu(); + params.setIsResizing(false); + document.body.style.cursor = ""; + }; + + return (e: MouseEvent | TouchEvent) => { + const { x: clientX, y: clientY } = _getClientCoordinates(e); + + // Check if drag has started + if (!params.hasDraggedRef.current) { + params.hasDraggedRef.current = checkDragStarted(clientX, clientY); + } + + if (!params.hasDraggedRef.current || !params.dragStartPos.current) { + return; + } + + const deltaX = clientX - params.dragStartPos.current.x; + const newWidth = params.initialMenuWidth.current + deltaX; + + // Handle edge cases + const shouldToggle = params.isCollapsed ? newWidth > 100 : newWidth < 20 && !hasTriggeredCollapse; + + if (shouldToggle) { + handleToggle(); + return; + } + + // Normal resize + if (!params.isCollapsed && !hasTriggeredCollapse) { + handleResize(newWidth); + } + }; +} + +// Custom hook for resize handling +function useResizeHandler({ + isResizing, + setIsResizing, + menuWidth, + setMenuWidth, + isCollapsed, + toggleMenu, + dragStartPos, + hasDraggedRef, + initialMenuWidth +}: { + isResizing: boolean; + setIsResizing: (value: boolean) => void; + menuWidth: number; + setMenuWidth: (value: number) => void; + isCollapsed: boolean; + toggleMenu: () => void; + dragStartPos: React.MutableRefObject<{ x: number; y: number } | null>; + hasDraggedRef: React.MutableRefObject; + initialMenuWidth: React.MutableRefObject; +}) { + useEffect(() => { + if (!isResizing) { + return; + } + + document.body.style.cursor = "col-resize"; + + const handleMove = createResizeHandler({ + isCollapsed, + dragStartPos, + hasDraggedRef, + initialMenuWidth, + toggleMenu, + setIsResizing, + setMenuWidth + }); + + const handleEnd = () => { + setIsResizing(false); + document.body.style.cursor = ""; + if (!isCollapsed) { + localStorage.setItem("side-menu-size", menuWidth.toString()); + } + dragStartPos.current = null; + }; + + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleEnd); + document.addEventListener("touchmove", handleMove); + document.addEventListener("touchend", handleEnd); + document.addEventListener("touchcancel", handleEnd); + + return () => { + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleEnd); + document.removeEventListener("touchmove", handleMove); + document.removeEventListener("touchend", handleEnd); + document.removeEventListener("touchcancel", handleEnd); + document.body.style.cursor = ""; + }; + }, [ + isResizing, + menuWidth, + isCollapsed, + toggleMenu, + dragStartPos, + hasDraggedRef, + initialMenuWidth, + setMenuWidth, + setIsResizing + ]); +} + // Helper function to save menu preference const _saveMenuPreference = (isCollapsed: boolean): void => { localStorage.setItem("side-menu-collapsed", isCollapsed.toString()); @@ -542,6 +722,16 @@ const _handleArrowKeyResize = ( window.dispatchEvent(new CustomEvent("side-menu-resize", { detail: { width: newWidth } })); }; +// Helper function to get client coordinates from mouse or touch event +const _getClientCoordinates = ( + e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent +): { x: number; y: number } => { + if ("touches" in e) { + return { x: e.touches[0].clientX, y: e.touches[0].clientY }; + } + return { x: e.clientX, y: e.clientY }; +}; + // Backdrop component for overlay mode const OverlayBackdrop = ({ closeOverlay }: { closeOverlay: () => void }) => (
; - handleResizeStart: (e: React.MouseEvent) => void; + handleResizeStart: (e: React.MouseEvent | React.TouchEvent) => void; hasDraggedRef: React.MutableRefObject; toggleMenu: () => void; menuWidth: number; setMenuWidth: (width: number) => void; ariaLabel: string; actualIsCollapsed: boolean; -}) => ( - + break; + default: + break; + } + }; + + return ( + + ); +}; + +// Helper to create toggle button content +const ToggleButtonContent = ({ isCollapsed }: { isCollapsed: boolean }) => ( + ); -export function SideMenu({ - children, +// Menu navigation component +const MenuNav = ({ + sideMenuRef, + actualIsCollapsed, + overlayMode, + isOverlayOpen, + isHidden, + className, + isResizing, + canResize, + menuWidth, + shouldShowResizeHandle, + handleResizeStart, + tenantName, + toggleButtonRef, + toggleMenu, + hasDraggedRef, + setMenuWidth, sidebarToggleAriaLabel, - mobileMenuAriaLabel, - topMenuContent, - tenantName -}: Readonly) { - const { className, forceCollapsed, overlayMode, isHidden } = useResponsiveMenu(); - const sideMenuRef = useRef(null); - const toggleButtonRef = useRef(null); + forceCollapsed, + children +}: { + sideMenuRef: React.RefObject; + actualIsCollapsed: boolean; + overlayMode: boolean; + isOverlayOpen: boolean; + isHidden: boolean; + className: string; + isResizing: boolean; + canResize: boolean; + menuWidth: number; + shouldShowResizeHandle: boolean; + handleResizeStart: (e: React.MouseEvent | React.TouchEvent) => void; + tenantName?: string; + toggleButtonRef: React.RefObject; + toggleMenu: () => void; + hasDraggedRef: React.RefObject; + setMenuWidth: (width: number) => void; + sidebarToggleAriaLabel?: string; + forceCollapsed: boolean; + children: React.ReactNode; +}) => ( + +); + +// Custom hook to manage menu state +function useMenuState(forceCollapsed: boolean) { const [isOverlayOpen, setIsOverlayOpen] = useState(false); - const [isResizing, setIsResizing] = useState(false); const [menuWidth, setMenuWidth] = useState(_getInitialMenuWidth); const [isCollapsed, setIsCollapsed] = useState(() => _getInitialCollapsedState(forceCollapsed)); const [userPreference, setUserPreference] = useState(_getUserPreference); // Update collapsed state when screen size changes useEffect(() => { - if (forceCollapsed) { - // Going to medium screen - force collapse but remember user preference - setIsCollapsed(true); - } else { - // Going back to large screen - restore user preference - setIsCollapsed(userPreference); - } + setIsCollapsed(forceCollapsed || userPreference); }, [forceCollapsed, userPreference]); - // The actual visual collapsed state - const actualIsCollapsed = overlayMode ? !isOverlayOpen : forceCollapsed || isCollapsed; - - // Check if we're on XL screen (for resize functionality) - const isXlScreen = !overlayMode && !forceCollapsed; + return { + isOverlayOpen, + setIsOverlayOpen, + menuWidth, + setMenuWidth, + isCollapsed, + setIsCollapsed, + userPreference, + setUserPreference + }; +} +// Custom hook for toggle menu logic +function useToggleMenu({ + overlayMode, + isOverlayOpen, + setIsOverlayOpen, + forceCollapsed, + isCollapsed, + setIsCollapsed, + setUserPreference, + toggleButtonRef +}: { + overlayMode: boolean; + isOverlayOpen: boolean; + setIsOverlayOpen: (value: boolean) => void; + forceCollapsed: boolean; + isCollapsed: boolean; + setIsCollapsed: (value: boolean) => void; + setUserPreference: (value: boolean) => void; + toggleButtonRef: React.RefObject; +}) { const toggleMenu = useCallback(() => { if (overlayMode) { const newIsOpen = !isOverlayOpen; @@ -675,217 +1002,137 @@ export function SideMenu({ _saveMenuPreference(newCollapsed); _dispatchMenuToggleEvent(false, !newCollapsed); } - // Maintain focus on the toggle button after state change setTimeout(() => _focusToggleButton(toggleButtonRef), 0); - }, [overlayMode, isOverlayOpen, forceCollapsed, isCollapsed]); + }, [ + overlayMode, + isOverlayOpen, + forceCollapsed, + isCollapsed, + setIsOverlayOpen, + setIsCollapsed, + setUserPreference, + toggleButtonRef + ]); const closeOverlay = useCallback(() => { - if (overlayMode && isOverlayOpen) { - setIsOverlayOpen(false); - _dispatchMenuToggleEvent(true, false); - } - }, [overlayMode, isOverlayOpen]); - - // Handle click outside for overlay - useEffect(() => { if (!overlayMode || !isOverlayOpen) { return; } + setIsOverlayOpen(false); + _dispatchMenuToggleEvent(true, false); + }, [overlayMode, isOverlayOpen, setIsOverlayOpen]); - const handleClickOutside = (event: MouseEvent) => { - if (sideMenuRef.current && !sideMenuRef.current.contains(event.target as Node)) { - closeOverlay(); - } - }; - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { - closeOverlay(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleEscape); - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleEscape); - }; - }, [overlayMode, isOverlayOpen, closeOverlay]); - - // Close mobile menu on resize - useEffect(() => { - const handleResize = () => { - const isNowMedium = window.matchMedia(MEDIA_QUERIES.sm).matches; - if (isNowMedium && isOverlayOpen) { - closeOverlay(); - } - }; - - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, [isOverlayOpen, closeOverlay]); + return { toggleMenu, closeOverlay }; +} - // Focus trap for overlay - useEffect(() => { - if (!isOverlayOpen || !overlayMode) { - return; - } +export function SideMenu({ + children, + sidebarToggleAriaLabel, + mobileMenuAriaLabel, + topMenuContent, + tenantName +}: Readonly) { + const { className, forceCollapsed, overlayMode, isHidden } = useResponsiveMenu(); + const sideMenuRef = useRef(null); + const toggleButtonRef = useRef(null); + const [isResizing, setIsResizing] = useState(false); - const handleKeyDown = (e: KeyboardEvent) => { - _handleFocusTrap(e, sideMenuRef); - }; + // Use the custom hook for menu state management + const { isOverlayOpen, setIsOverlayOpen, menuWidth, setMenuWidth, isCollapsed, setIsCollapsed, setUserPreference } = + useMenuState(forceCollapsed); - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isOverlayOpen, overlayMode]); + // Compute derived states + const actualIsCollapsed = overlayMode ? !isOverlayOpen : forceCollapsed || isCollapsed; + const shouldShowResizeHandle = !overlayMode && !forceCollapsed; + const canResize = shouldShowResizeHandle && !isCollapsed; + + // Use the toggle menu hook + const { toggleMenu, closeOverlay } = useToggleMenu({ + overlayMode, + isOverlayOpen, + setIsOverlayOpen, + forceCollapsed, + isCollapsed, + setIsCollapsed, + setUserPreference, + toggleButtonRef + }); + + // Use custom hooks for overlay behavior + useOverlayHandlers({ + overlayMode, + isOverlayOpen, + closeOverlay, + sideMenuRef + }); // Track mouse movement to detect dragging const dragStartPos = useRef<{ x: number; y: number } | null>(null); const hasDraggedRef = useRef(false); + const initialMenuWidth = useRef(0); - // Handle resize drag - const handleResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsResizing(true); - dragStartPos.current = { x: e.clientX, y: e.clientY }; - hasDraggedRef.current = false; - }, []); - - useEffect(() => { - if (!isResizing) { - return; - } - - // Add global cursor style - document.body.style.cursor = "col-resize"; - - let hasTriggeredCollapse = false; - - const handleMouseMove = (e: MouseEvent) => { - const mouseX = e.clientX - 8; - - // Check if mouse has moved more than 5px from start (indicates dragging) - _checkDragStarted(e, dragStartPos, hasDraggedRef); - - // If dragging from collapsed state and mouse is past collapsed width, expand - if (isCollapsed && mouseX > 100) { - toggleMenu(); - setIsResizing(false); - document.body.style.cursor = ""; - return; - } - - // If dragging to edge from expanded state, collapse - if (!isCollapsed && mouseX < 20 && !hasTriggeredCollapse) { - hasTriggeredCollapse = true; - toggleMenu(); - setIsResizing(false); - document.body.style.cursor = ""; - return; - } - - // Normal resize when expanded - _handleResizeAction(mouseX, isCollapsed, hasTriggeredCollapse, setMenuWidth); - }; - - const handleMouseUp = () => { - setIsResizing(false); - document.body.style.cursor = ""; - // Only save if we didn't trigger collapse - if (!hasTriggeredCollapse && !isCollapsed) { - localStorage.setItem("side-menu-size", menuWidth.toString()); - } - dragStartPos.current = null; - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); + // Handle resize drag for both mouse and touch + const handleResizeStart = useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + + // Handle both mouse and touch events + const coords = _getClientCoordinates(e); + dragStartPos.current = coords; + hasDraggedRef.current = false; + initialMenuWidth.current = menuWidth; + }, + [menuWidth] + ); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - document.body.style.cursor = ""; - }; - }, [isResizing, menuWidth, isCollapsed, toggleMenu]); + // Extract resize handling into a custom hook to reduce complexity + useResizeHandler({ + isResizing, + setIsResizing, + menuWidth, + setMenuWidth, + isCollapsed, + toggleMenu, + dragStartPos, + hasDraggedRef, + initialMenuWidth + }); + + const menuContent = ( + + + + {children} + + + + ); return ( <> - {/* Backdrop for overlay mode */} {overlayMode && isOverlayOpen && } - - - - - - - + {menuContent} {/* Mobile floating button */} From abca6e016ae0de6b1b92a0527b071e9e7a522592 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 26 Jul 2025 00:29:53 +0200 Subject: [PATCH 25/37] Fix toast rounded corners being cut off on mobile screens --- application/shared-webapp/ui/components/Toast.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/shared-webapp/ui/components/Toast.tsx b/application/shared-webapp/ui/components/Toast.tsx index 3f20847af..347c18790 100644 --- a/application/shared-webapp/ui/components/Toast.tsx +++ b/application/shared-webapp/ui/components/Toast.tsx @@ -135,7 +135,7 @@ function ToastRegion({ state, ...props }: Readonly {state.visibleToasts.map((toast) => ( @@ -145,7 +145,7 @@ function ToastRegion({ state, ...props }: Readonly Date: Sat, 26 Jul 2025 19:00:42 +0200 Subject: [PATCH 26/37] Implement infinite scroll for users on mobile devices without pagination --- .../users/-components/UserProfileSidePane.tsx | 8 +- .../admin/users/-components/UserTable.tsx | 71 +++++++++-- .../admin/users/-hooks/useInfiniteUsers.ts | 116 ++++++++++++++++++ 3 files changed, 176 insertions(+), 19 deletions(-) create mode 100644 application/account-management/WebApp/routes/admin/users/-hooks/useInfiniteUsers.ts diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index c838012b7..8cde0049d 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -161,10 +161,6 @@ function useSidePaneAccessibility( const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && isOpen) { event.preventDefault(); - const _isSmallScreen = !window.matchMedia(MEDIA_QUERIES.md).matches; - - // Don't restore focus - let the parent handle it - onClose(); } }; @@ -273,8 +269,8 @@ export function UserProfileSidePane({
- {/* Notice when user is not in current filtered view */} - {!isUserInCurrentView && ( + {/* Notice when user is not in current filtered view - only show on desktop with pagination */} + {!isUserInCurrentView && !isSmallScreen && (
diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx index 65151826c..55c4ca994 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -15,9 +15,10 @@ import { formatDate } from "@repo/utils/date/formatDate"; import { getInitials } from "@repo/utils/string/getInitials"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { EllipsisVerticalIcon, SettingsIcon, Trash2Icon, UserIcon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { Selection, SortDescriptor } from "react-aria-components"; import { MenuTrigger, TableBody } from "react-aria-components"; +import { useInfiniteUsers } from "../-hooks/useInfiniteUsers"; type UserDetails = components["schemas"]["UserDetails"]; @@ -49,8 +50,10 @@ export function UserTable({ direction: sortOrder === "Ascending" ? "ascending" : "descending" })); const [isKeyboardNavigation, setIsKeyboardNavigation] = useState(false); + const [isMobile, setIsMobile] = useState(!window.matchMedia(MEDIA_QUERIES.sm).matches); - const { data: users, isLoading } = api.useQuery("get", "/api/account-management/users", { + // Use regular query for desktop + const { data: desktopUsers, isLoading: isDesktopLoading } = api.useQuery("get", "/api/account-management/users", { params: { query: { Search: search, @@ -62,9 +65,32 @@ export function UserTable({ SortOrder: sortOrder, PageOffset: pageOffset } - } + }, + enabled: !isMobile }); + // Use infinite scroll for mobile + const { + users: mobileUsers, + isLoading: isMobileLoading, + isLoadingMore, + hasMore, + loadMore + } = useInfiniteUsers({ + search, + userRole, + userStatus, + startDate, + endDate, + orderBy, + sortOrder, + enabled: isMobile + }); + + // Select data based on device + const users = isMobile ? { users: mobileUsers, totalPages: 1, currentPageOffset: 0 } : desktopUsers; + const isLoading = isMobile ? isMobileLoading : isDesktopLoading; + const handlePageChange = useCallback( (page: number) => { navigate({ @@ -194,12 +220,33 @@ export function UserTable({ const handleResize = () => { setIsMinSm(window.matchMedia(MEDIA_QUERIES.sm).matches); setIsMinMd(window.matchMedia(MEDIA_QUERIES.md).matches); + setIsMobile(!window.matchMedia(MEDIA_QUERIES.sm).matches); }; window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); + // IntersectionObserver for infinite scroll on mobile + const loadMoreRef = useRef(null); + useEffect(() => { + if (!isMobile || !loadMoreRef.current) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isLoadingMore) { + loadMore(); + } + }, + { threshold: 0.5 } + ); + + observer.observe(loadMoreRef.current); + return () => observer.disconnect(); + }, [isMobile, hasMore, isLoadingMore, loadMore]); + if (isLoading) { return null; } @@ -219,6 +266,7 @@ export function UserTable({ sortDescriptor={sortDescriptor} onSortChange={handleSortChange} aria-label={t`Users`} + className={isMobile ? "[&>div]:h-[calc(100vh-14rem)]" : ""} > - {users && ( -
- + {/* Mobile: Loading indicator for infinite scroll */} + {isMobile &&
} + + {/* Desktop: Regular pagination */} + {!isMobile && users && ( +
)} diff --git a/application/account-management/WebApp/routes/admin/users/-hooks/useInfiniteUsers.ts b/application/account-management/WebApp/routes/admin/users/-hooks/useInfiniteUsers.ts new file mode 100644 index 000000000..06f4c1ed4 --- /dev/null +++ b/application/account-management/WebApp/routes/admin/users/-hooks/useInfiniteUsers.ts @@ -0,0 +1,116 @@ +import { + type SortOrder, + type SortableUserProperties, + type UserRole, + type UserStatus, + api, + type components +} from "@/shared/lib/api/client"; +import { useCallback, useEffect, useState } from "react"; + +type UserDetails = components["schemas"]["UserDetails"]; + +interface UseInfiniteUsersParams { + search?: string; + userRole?: UserRole | null; + userStatus?: UserStatus | null; + startDate?: string; + endDate?: string; + orderBy?: SortableUserProperties; + sortOrder?: SortOrder; + enabled: boolean; +} + +export function useInfiniteUsers({ + search, + userRole, + userStatus, + startDate, + endDate, + orderBy, + sortOrder, + enabled +}: UseInfiniteUsersParams) { + const [allUsers, setAllUsers] = useState([]); + const [currentPage, setCurrentPage] = useState(0); + const [totalPages, setTotalPages] = useState(null); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + // Initial load query + const { data: initialData, isLoading: isInitialLoading } = api.useQuery("get", "/api/account-management/users", { + params: { + query: { + Search: search, + UserRole: userRole, + UserStatus: userStatus, + StartDate: startDate, + EndDate: endDate, + OrderBy: orderBy, + SortOrder: sortOrder, + PageOffset: 0 + } + }, + enabled + }); + + // State for next page to load + const [nextPageToLoad, setNextPageToLoad] = useState(null); + + // Load more query + const { data: moreData } = api.useQuery("get", "/api/account-management/users", { + params: { + query: { + Search: search, + UserRole: userRole, + UserStatus: userStatus, + StartDate: startDate, + EndDate: endDate, + OrderBy: orderBy, + SortOrder: sortOrder, + PageOffset: nextPageToLoad + } + }, + enabled: enabled && nextPageToLoad !== null && isLoadingMore + }); + + // Reset when filters change + useEffect(() => { + if (initialData) { + setAllUsers(initialData.users || []); + setTotalPages(initialData.totalPages || 1); + setCurrentPage(0); + setNextPageToLoad(null); + } + }, [initialData]); + + // Append more data when loaded + useEffect(() => { + if (moreData && isLoadingMore) { + setAllUsers((prev) => [...prev, ...(moreData.users || [])]); + setIsLoadingMore(false); + setNextPageToLoad(null); + } + }, [moreData, isLoadingMore]); + + const loadMore = useCallback(() => { + if (isLoadingMore || !totalPages || currentPage >= totalPages - 1) { + return; + } + + const nextPage = currentPage + 1; + setCurrentPage(nextPage); + setNextPageToLoad(nextPage); + setIsLoadingMore(true); + }, [currentPage, totalPages, isLoadingMore]); + + const hasMore = totalPages !== null && currentPage < totalPages - 1; + + return { + users: allUsers, + isLoading: isInitialLoading, + isLoadingMore, + hasMore, + loadMore, + totalPages + }; +} From 483ebb32e1cdbf94535a042e44c23b6f899ddb61 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 27 Jul 2025 02:40:21 +0200 Subject: [PATCH 27/37] Add iOS home screen installation prompt with swipe-to-dismiss --- .../WebApp/routes/__root.tsx | 2 + .../back-office/WebApp/routes/__root.tsx | 2 + .../ui/components/AddToHomescreen.tsx | 114 ++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 application/shared-webapp/ui/components/AddToHomescreen.tsx diff --git a/application/account-management/WebApp/routes/__root.tsx b/application/account-management/WebApp/routes/__root.tsx index be3ad397f..e0bbf8525 100644 --- a/application/account-management/WebApp/routes/__root.tsx +++ b/application/account-management/WebApp/routes/__root.tsx @@ -5,6 +5,7 @@ import { ErrorPage } from "@repo/infrastructure/errorComponents/ErrorPage"; import { NotFound } from "@repo/infrastructure/errorComponents/NotFoundPage"; import { ReactAriaRouterProvider } from "@repo/infrastructure/router/ReactAriaRouterProvider"; import { useInitializeLocale } from "@repo/infrastructure/translations/useInitializeLocale"; +import { AddToHomescreen } from "@repo/ui/components/AddToHomescreen"; import { ThemeModeProvider } from "@repo/ui/theme/mode/ThemeMode"; import { QueryClientProvider } from "@tanstack/react-query"; import { Outlet, createRootRoute, useNavigate } from "@tanstack/react-router"; @@ -24,6 +25,7 @@ function Root() { navigate(options)}> + diff --git a/application/back-office/WebApp/routes/__root.tsx b/application/back-office/WebApp/routes/__root.tsx index be3ad397f..e0bbf8525 100644 --- a/application/back-office/WebApp/routes/__root.tsx +++ b/application/back-office/WebApp/routes/__root.tsx @@ -5,6 +5,7 @@ import { ErrorPage } from "@repo/infrastructure/errorComponents/ErrorPage"; import { NotFound } from "@repo/infrastructure/errorComponents/NotFoundPage"; import { ReactAriaRouterProvider } from "@repo/infrastructure/router/ReactAriaRouterProvider"; import { useInitializeLocale } from "@repo/infrastructure/translations/useInitializeLocale"; +import { AddToHomescreen } from "@repo/ui/components/AddToHomescreen"; import { ThemeModeProvider } from "@repo/ui/theme/mode/ThemeMode"; import { QueryClientProvider } from "@tanstack/react-query"; import { Outlet, createRootRoute, useNavigate } from "@tanstack/react-router"; @@ -24,6 +25,7 @@ function Root() { navigate(options)}> + diff --git a/application/shared-webapp/ui/components/AddToHomescreen.tsx b/application/shared-webapp/ui/components/AddToHomescreen.tsx new file mode 100644 index 000000000..877ccb804 --- /dev/null +++ b/application/shared-webapp/ui/components/AddToHomescreen.tsx @@ -0,0 +1,114 @@ +import { Share, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "./Button"; +import { Heading } from "./Heading"; +import { Text } from "./Text"; + +const SESSION_COOKIE_NAME = "add_to_homescreen_dismissed"; + +export function AddToHomescreen() { + const [showPrompt, setShowPrompt] = useState(false); + const [isStandalone, setIsStandalone] = useState(false); + const [translateY, setTranslateY] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const startY = useRef(0); + const currentY = useRef(0); + + useEffect(() => { + // More comprehensive iOS detection including iPad on iOS 13+ + const isIos = + (/iPad|iPhone|iPod/.test(navigator.userAgent) && !("MSStream" in window)) || + (navigator.userAgent.includes("Mac") && "ontouchend" in document); + + const isPwa = + window.matchMedia("(display-mode: standalone)").matches || + ("standalone" in window.navigator && (window.navigator as unknown as { standalone?: boolean }).standalone) || + false; + + setIsStandalone(isPwa); + + if (isIos && !isPwa) { + const hasSessionDismissed = document.cookie.includes(`${SESSION_COOKIE_NAME}=true`); + + if (!hasSessionDismissed) { + setShowPrompt(true); + } + } + }, []); + + const handleDismiss = () => { + setShowPrompt(false); + // Set persistent cookie for 7 days when X button is clicked + const date = new Date(); + date.setTime(date.getTime() + 7 * 24 * 60 * 60 * 1000); + document.cookie = `${SESSION_COOKIE_NAME}=true; expires=${date.toUTCString()}; path=/`; + }; + + const handleTouchStart = (e: React.TouchEvent) => { + setIsDragging(true); + startY.current = e.touches[0].clientY; + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (!isDragging) { + return; + } + + currentY.current = e.touches[0].clientY; + const diff = currentY.current - startY.current; + + // Only allow upward swipes + if (diff < 0) { + setTranslateY(diff); + } + }; + + const handleTouchEnd = () => { + setIsDragging(false); + + // If swiped up more than 50px, dismiss + if (translateY < -50) { + setShowPrompt(false); + // Set session cookie - will expire when browser session ends + document.cookie = `${SESSION_COOKIE_NAME}=true; path=/`; + } else { + // Snap back + setTranslateY(0); + } + }; + + if (!showPrompt || isStandalone) { + return null; + } + + return ( +
+
+
+ PlatformPlatform +
+ + Install PlatformPlatform + + + Add to your home screen for a faster, app-like experience. Tap {" "} + then "Add to Home Screen" + +
+ +
+
+
+ ); +} From c5de7d61965d7d11304b2d445b7e79ef32353fd3 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 26 Jul 2025 20:02:06 +0200 Subject: [PATCH 28/37] Introduce sticker headers on mobile devices --- .../WebApp/routes/admin/account/index.tsx | 9 +- .../WebApp/routes/admin/index.tsx | 10 +- .../admin/users/-components/UserToolbar.tsx | 2 +- .../WebApp/routes/admin/users/index.tsx | 19 +- .../WebApp/routes/back-office/index.tsx | 18 +- .../shared-webapp/ui/components/AppLayout.tsx | 271 ++++++++++++++++-- application/shared-webapp/ui/tailwind.css | 16 ++ 7 files changed, 288 insertions(+), 57 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/account/index.tsx b/application/account-management/WebApp/routes/admin/account/index.tsx index 395ae7475..344bf4ff6 100644 --- a/application/account-management/WebApp/routes/admin/account/index.tsx +++ b/application/account-management/WebApp/routes/admin/account/index.tsx @@ -58,14 +58,9 @@ export function AccountSettings() { } + title={t`Account settings`} + subtitle={t`Manage your account here.`} > -

- Account settings -

-

- Manage your account here. -

-
- }> -

{userInfo?.firstName ? Welcome home, {userInfo.firstName} : Welcome home}

-

- Here's your overview of what's happening. -

+ } + title={userInfo?.firstName ? t`Welcome home, ${userInfo.firstName}` : t`Welcome home`} + subtitle={t`Here's your overview of what's happening.`} + >
+
onSelectedUsersChange([])} />
{selectedUsers.length < 2 && isOwner && ( diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index a8849f39f..ee199b478 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -1,6 +1,7 @@ import FederatedSideMenu from "@/federated-modules/sideMenu/FederatedSideMenu"; import { TopMenu } from "@/shared/components/topMenu"; import { SortOrder, SortableUserProperties, UserRole, UserStatus, api, type components } from "@/shared/lib/api/client"; +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { AppLayout } from "@repo/ui/components/AppLayout"; import { Breadcrumb } from "@repo/ui/components/Breadcrumbs"; @@ -140,18 +141,14 @@ export default function UsersPage() { } + title={t`Users`} + subtitle={t`Manage your users and permissions here.`} + scrollAwayHeader={true} > -
-

- Users -

-

- Manage your users and permissions here. -

- -
- -
+
+ +
+
- }> -

- Welcome to the Back Office -

-

- - Manage tenants, view system data, see exceptions, and perform various tasks for operational and support - teams. - -

+ } + title={t`Welcome to the Back Office`} + subtitle={t`Manage tenants, view system data, see exceptions, and perform various tasks for operational and support teams.`} + > +
); diff --git a/application/shared-webapp/ui/components/AppLayout.tsx b/application/shared-webapp/ui/components/AppLayout.tsx index 5dfa887e8..f02da34f4 100644 --- a/application/shared-webapp/ui/components/AppLayout.tsx +++ b/application/shared-webapp/ui/components/AppLayout.tsx @@ -1,5 +1,5 @@ -import type React from "react"; -import { useEffect } from "react"; +import React, { useEffect, useRef, useState } from "react"; +import { cn } from "../cn"; import { useSideMenuLayout } from "../hooks/useSideMenuLayout"; type AppLayoutVariant = "full" | "center"; @@ -11,6 +11,9 @@ type AppLayoutProps = { maxWidth?: string; sidePane?: React.ReactNode; paddingBottom?: string; + title?: React.ReactNode; + subtitle?: React.ReactNode; + scrollAwayHeader?: boolean; }; /** @@ -30,18 +33,9 @@ type AppLayoutProps = { * - full: Content takes full width with standard padding * - center: Content is always centered with configurable max width (default: 640px) */ -export function AppLayout({ - children, - topMenu, - variant = "full", - maxWidth = "640px", - sidePane -}: Readonly) { - const { className, style, isOverlayOpen, isMobileMenuOpen } = useSideMenuLayout(); - - // Prevent body scroll when overlay is open +function useBodyScrollLock(isLocked: boolean) { useEffect(() => { - if (isOverlayOpen) { + if (isLocked) { document.body.style.overflow = "hidden"; } else { document.body.style.overflow = ""; @@ -50,7 +44,212 @@ export function AppLayout({ return () => { document.body.style.overflow = ""; }; - }, [isOverlayOpen]); + }, [isLocked]); +} + +function useStickyHeader(enabled: boolean, headerRef: React.RefObject) { + const [isSticky, setIsSticky] = useState(false); + const observerRef = useRef(null); + + useEffect(() => { + if (!enabled) { + return; + } + + const threshold = 0.1; + + observerRef.current = new IntersectionObserver( + ([entry]) => { + setIsSticky(!entry.isIntersecting); + }, + { + threshold, + rootMargin: "-60px 0px 0px 0px" + } + ); + + if (headerRef.current) { + observerRef.current.observe(headerRef.current); + } + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, [enabled, headerRef]); + + return isSticky; +} + +function useScrollAwayHeader(enabled: boolean, contentRef: React.RefObject) { + const [scrollProgress, setScrollProgress] = useState(0); + const [headerHeight, setHeaderHeight] = useState(0); + const headerRef = useRef(null); + + useEffect(() => { + if (!enabled || !contentRef.current) { + return; + } + + const updateHeaderHeight = () => { + const header = contentRef.current?.querySelector(".scroll-away-header") as HTMLDivElement; + if (header) { + headerRef.current = header; + // Only count the header height, not the entire content + const headerContent = header.querySelector(".mb-4") as HTMLDivElement; + setHeaderHeight(headerContent ? headerContent.offsetHeight : header.offsetHeight); + } + }; + + const handleScroll = () => { + if (!contentRef.current || !headerRef.current) { + return; + } + + const scrollTop = contentRef.current.scrollTop; + const maxScroll = Math.max(headerHeight - 60, 0); // Leave 60px for sticky header + const progress = maxScroll > 0 ? Math.min(scrollTop / maxScroll, 1) : 0; + + setScrollProgress(progress); + }; + + updateHeaderHeight(); + window.addEventListener("resize", updateHeaderHeight); + + const scrollElement = contentRef.current; + scrollElement.addEventListener("scroll", handleScroll); + handleScroll(); + + return () => { + window.removeEventListener("resize", updateHeaderHeight); + scrollElement.removeEventListener("scroll", handleScroll); + }; + }, [enabled, contentRef, headerHeight]); + + return { scrollProgress, isFullyScrolled: scrollProgress >= 1 }; +} + +interface HeaderContentProps { + title: React.ReactNode; + subtitle?: React.ReactNode; + isSticky: boolean; +} + +const HeaderContent = React.forwardRef(({ title, subtitle, isSticky }, ref) => ( +
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+)); + +HeaderContent.displayName = "HeaderContent"; + +interface ScrollAwayContentProps { + title: React.ReactNode; + subtitle?: React.ReactNode; + scrollProgress: number; + headerRef: React.RefObject; + children: React.ReactNode; +} + +function ScrollAwayContent({ title, subtitle, scrollProgress, headerRef, children }: ScrollAwayContentProps) { + return ( + <> + {/* Mobile version with scroll-away header */} +
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+
{children}
+
+ + {/* Desktop version - no scroll away behavior */} +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
{children}
+
+ + ); +} + +interface StandardContentProps { + variant: AppLayoutVariant; + maxWidth: string; + title?: React.ReactNode; + subtitle?: React.ReactNode; + headerRef: React.RefObject; + isSticky: boolean; + children: React.ReactNode; +} + +function StandardContent({ variant, maxWidth, title, subtitle, headerRef, isSticky, children }: StandardContentProps) { + if (variant === "center") { + return ( +
+
+ {title && } +
{children}
+
+
+ ); + } + + return ( +
+ {title && } +
{children}
+
+ ); +} + +export function AppLayout({ + children, + topMenu, + variant = "full", + maxWidth = "640px", + sidePane, + title, + subtitle, + scrollAwayHeader = false +}: Readonly) { + const { className, style, isOverlayOpen, isMobileMenuOpen } = useSideMenuLayout(); + const headerRef = useRef(null); + const contentRef = useRef(null); + + useBodyScrollLock(isOverlayOpen); + const isSticky = useStickyHeader(!!title && !scrollAwayHeader, headerRef); + const { scrollProgress, isFullyScrolled } = useScrollAwayHeader(scrollAwayHeader && !!title, contentRef); return (
@@ -65,6 +264,26 @@ export function AppLayout({ className={`${className} ${sidePane ? "grid grid-cols-[1fr_384px] sm:grid" : "flex flex-col"} h-full overflow-hidden`} style={style} > + {/* Mobile sticky header - shown differently based on scroll mode */} + {title && ( +
+
{title}
+
+ )} {/* Fixed TopMenu with blur effect - contains breadcrumbs and secondary functions */}
- {variant === "center" ? ( -
-
- {children} -
-
+ {scrollAwayHeader && title ? ( + + {children} + ) : ( - children + + {children} + )} diff --git a/application/shared-webapp/ui/tailwind.css b/application/shared-webapp/ui/tailwind.css index 44fefc08e..6bfdc5d70 100644 --- a/application/shared-webapp/ui/tailwind.css +++ b/application/shared-webapp/ui/tailwind.css @@ -206,4 +206,20 @@ body { [role="button"] { -webkit-tap-highlight-color: transparent; } + + /* Override table height for scroll-away header on mobile */ + .scroll-away-header ~ * [aria-hidden="true"].relative.h-full.w-full { + height: auto !important; + } + + .scroll-away-header ~ * [aria-hidden="true"] .absolute.top-0.right-0.bottom-0.left-0 { + position: relative !important; + inset: auto !important; + height: auto !important; + } + + .scroll-away-header ~ * [aria-hidden="true"] .relative.h-full.w-full.scroll-pt-\[2\.281rem\].overflow-auto { + height: auto !important; + overflow: visible !important; + } } From ae76c701c12639d69f7bec7fbea01ccc304fb751 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 27 Jul 2025 03:34:25 +0200 Subject: [PATCH 29/37] Fix complexity in UserTable --- .../admin/users/-components/UserTable.tsx | 398 ++++++++---------- .../ui/hooks/useInfiniteScroll.ts | 32 ++ .../ui/hooks/useKeyboardNavigation.ts | 23 + .../ui/hooks/useViewportResize.ts | 17 + .../shared-webapp/ui/utils/responsive.ts | 26 ++ 5 files changed, 276 insertions(+), 220 deletions(-) create mode 100644 application/shared-webapp/ui/hooks/useInfiniteScroll.ts create mode 100644 application/shared-webapp/ui/hooks/useKeyboardNavigation.ts create mode 100644 application/shared-webapp/ui/hooks/useViewportResize.ts diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx index 55c4ca994..61f947bb3 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -10,12 +10,15 @@ import { Menu, MenuItem, MenuSeparator } from "@repo/ui/components/Menu"; import { Pagination } from "@repo/ui/components/Pagination"; import { Cell, Column, Row, Table, TableHeader } from "@repo/ui/components/Table"; import { Text } from "@repo/ui/components/Text"; -import { MEDIA_QUERIES, isTouchDevice } from "@repo/ui/utils/responsive"; +import { useInfiniteScroll } from "@repo/ui/hooks/useInfiniteScroll"; +import { useKeyboardNavigation } from "@repo/ui/hooks/useKeyboardNavigation"; +import { useViewportResize } from "@repo/ui/hooks/useViewportResize"; +import { isMediumViewportOrLarger, isSmallViewportOrLarger, isTouchDevice } from "@repo/ui/utils/responsive"; import { formatDate } from "@repo/utils/date/formatDate"; import { getInitials } from "@repo/utils/string/getInitials"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { EllipsisVerticalIcon, SettingsIcon, Trash2Icon, UserIcon } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { Selection, SortDescriptor } from "react-aria-components"; import { MenuTrigger, TableBody } from "react-aria-components"; import { useInfiniteUsers } from "../-hooks/useInfiniteUsers"; @@ -31,6 +34,7 @@ interface UserTableProps { onUsersLoaded?: (users: UserDetails[]) => void; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Component handles complex table interactions including sorting, selection, pagination and infinite scroll export function UserTable({ selectedUsers, onSelectedUsersChange, @@ -49,8 +53,8 @@ export function UserTable({ column: orderBy ?? "email", direction: sortOrder === "Ascending" ? "ascending" : "descending" })); - const [isKeyboardNavigation, setIsKeyboardNavigation] = useState(false); - const [isMobile, setIsMobile] = useState(!window.matchMedia(MEDIA_QUERIES.sm).matches); + const isKeyboardNavigation = useKeyboardNavigation(); + const isMobile = useViewportResize(); // Use regular query for desktop const { data: desktopUsers, isLoading: isDesktopLoading } = api.useQuery("get", "/api/account-management/users", { @@ -126,37 +130,11 @@ export function UserTable({ }, [onSelectedUsersChange, pageOffset]); useEffect(() => { - if (users?.users) { - onUsersLoaded?.(users.users); + if (users?.users && onUsersLoaded) { + onUsersLoaded(users.users); } }, [users?.users, onUsersLoaded]); - // Track keyboard vs mouse interaction - useEffect(() => { - const handleKeyDown = () => { - setIsKeyboardNavigation(true); - }; - - const handleMouseDown = () => { - setIsKeyboardNavigation(false); - }; - - const handlePointerDown = () => { - setIsKeyboardNavigation(false); - }; - - // Use capture phase to ensure we set the flag before any click handlers - document.addEventListener("keydown", handleKeyDown, true); - document.addEventListener("mousedown", handleMouseDown, true); - document.addEventListener("pointerdown", handlePointerDown, true); - - return () => { - document.removeEventListener("keydown", handleKeyDown, true); - document.removeEventListener("mousedown", handleMouseDown, true); - document.removeEventListener("pointerdown", handlePointerDown, true); - }; - }, []); - const handleSelectionChange = useCallback( (keys: Selection) => { if (keys === "all") { @@ -175,11 +153,16 @@ export function UserTable({ } // Single user selected - check if we should auto-open profile - const isSmallScreen = isTouchDevice() || !window.matchMedia(MEDIA_QUERIES.md).matches; - const shouldAutoOpen = !isSmallScreen || !isKeyboardNavigation; + if (isKeyboardNavigation) { + return; // Don't auto-open on keyboard navigation + } - if (shouldAutoOpen) { - onViewProfile(selectedUsersList[0], isKeyboardNavigation); + // For touch devices in single selection mode, always open profile + if (isTouchDevice() || !isMediumViewportOrLarger()) { + onViewProfile(selectedUsersList[0], false); + } else if (isMediumViewportOrLarger()) { + // For desktop, also open profile + onViewProfile(selectedUsersList[0], false); } }, [users?.users, onSelectedUsersChange, onViewProfile, isKeyboardNavigation] @@ -188,221 +171,196 @@ export function UserTable({ // Handle Enter key to open profile useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Only handle if focus is within the table area + if (e.key !== "Enter" || selectedUsers.length !== 1) { + return; + } + const activeElement = document.activeElement; const tableContainer = document.querySelector(".min-h-0.flex-1"); + if (!tableContainer?.contains(activeElement)) { + return; + } - if (tableContainer?.contains(activeElement)) { - if (e.key === "Enter" && selectedUsers.length === 1) { - const target = e.target as HTMLElement; - // Don't interfere with menu triggers or buttons - if (target.tagName !== "BUTTON" && !target.closest("button")) { - e.preventDefault(); - e.stopPropagation(); - onViewProfile(selectedUsers[0], true); - } - } + const target = e.target as HTMLElement; + if (target.tagName === "BUTTON" || target.closest("button")) { + return; } + + e.preventDefault(); + e.stopPropagation(); + onViewProfile(selectedUsers[0], true); }; - // Add the handler for all screen sizes document.addEventListener("keydown", handleKeyDown, true); return () => { document.removeEventListener("keydown", handleKeyDown, true); }; }, [selectedUsers, onViewProfile]); - // Media queries for responsive columns - must be before early returns - const [isMinSm, setIsMinSm] = useState(window.matchMedia(MEDIA_QUERIES.sm).matches); - const [isMinMd, setIsMinMd] = useState(window.matchMedia(MEDIA_QUERIES.md).matches); - - useEffect(() => { - const handleResize = () => { - setIsMinSm(window.matchMedia(MEDIA_QUERIES.sm).matches); - setIsMinMd(window.matchMedia(MEDIA_QUERIES.md).matches); - setIsMobile(!window.matchMedia(MEDIA_QUERIES.sm).matches); - }; - - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); - - // IntersectionObserver for infinite scroll on mobile - const loadMoreRef = useRef(null); - useEffect(() => { - if (!isMobile || !loadMoreRef.current) { - return; - } - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && hasMore && !isLoadingMore) { - loadMore(); - } - }, - { threshold: 0.5 } - ); - - observer.observe(loadMoreRef.current); - return () => observer.disconnect(); - }, [isMobile, hasMore, isLoadingMore, loadMore]); + // Use infinite scroll hook for mobile + const loadMoreRef = useInfiniteScroll({ + enabled: isMobile, + hasMore, + isLoadingMore, + onLoadMore: loadMore + }); if (isLoading) { return null; } - const currentPage = (users?.currentPageOffset ?? 0) + 1; - // Use single selection on touch devices regardless of screen size - const isSmallScreen = isTouchDevice() || !window.matchMedia(MEDIA_QUERIES.md).matches; + const currentPage = users ? users.currentPageOffset + 1 : 1; return ( <> - user.id))} - onSelectionChange={handleSelectionChange} - sortDescriptor={sortDescriptor} - onSortChange={handleSortChange} - aria-label={t`Users`} - className={isMobile ? "[&>div]:h-[calc(100vh-14rem)]" : ""} - > - - - Name - - {isMinSm && ( - - Email - - )} - {isMinMd && ( - - Created - - )} - {isMinMd && ( - - Modified +
+
user.id))} + onSelectionChange={handleSelectionChange} + sortDescriptor={sortDescriptor} + onSortChange={handleSortChange} + aria-label={t`Users`} + className={isMobile ? "[&>div]:h-[calc(100vh-14rem)]" : ""} + > + + + Name - )} - {isMinSm && ( - - Role - - )} - - - {users?.users.map((user) => ( - - -
-
- -
-
- - {user.firstName} {user.lastName} - - {user.emailConfirmed ? null : ( - - Pending - - )} + {isSmallViewportOrLarger() && ( + + Email + + )} + {isMediumViewportOrLarger() && ( + + Created + + )} + {isMediumViewportOrLarger() && ( + + Modified + + )} + {isSmallViewportOrLarger() && ( + + Role + + )} + + + {users?.users.map((user) => ( + + +
+
+ +
+
+ + {user.firstName} {user.lastName} + + {user.emailConfirmed ? null : ( + + Pending + + )} +
+ {user.title ?? ""}
- {user.title ?? ""}
-
- - {isMinSm && ( - - {user.email} - )} - {isMinMd && ( - - - {formatDate(user.createdAt)} - - - )} - {isMinMd && ( - - - {formatDate(user.modifiedAt)} - - - )} - {isMinSm && ( - -
- {getUserRoleLabel(user.role)} - { - if (isOpen) { - onSelectedUsersChange([user]); - } - }} - > - - - onViewProfile(user, false)}> - - View profile - - {userInfo?.role === "Owner" && ( - <> - onChangeRole(user)} - > - - Change role - - - onDeleteUser(user)} - > - - - Delete - - - - )} - - -
-
- )} - - ))} - -
+ {isSmallViewportOrLarger() && ( + + {user.email} + + )} + {isMediumViewportOrLarger() && ( + + + {formatDate(user.createdAt)} + + + )} + {isMediumViewportOrLarger() && ( + + + {formatDate(user.modifiedAt)} + + + )} + {isSmallViewportOrLarger() && ( + +
+ {getUserRoleLabel(user.role)} + { + if (isOpen) { + onSelectedUsersChange([user]); + } + }} + > + + + onViewProfile(user, false)}> + + View profile + + {userInfo?.role === "Owner" && ( + <> + onChangeRole(user)} + > + + Change role + + + onDeleteUser(user)} + > + + + Delete + + + + )} + + +
+
+ )} + + ))} + + +
{/* Mobile: Loading indicator for infinite scroll */} {isMobile &&
} {/* Desktop: Regular pagination */} {!isMobile && users && ( -
+
void; +} + +export function useInfiniteScroll({ enabled, hasMore, isLoadingMore, onLoadMore }: UseInfiniteScrollProps) { + const loadMoreRef = useRef(null); + + useEffect(() => { + if (!enabled || !loadMoreRef.current || !hasMore || isLoadingMore) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + onLoadMore(); + } + }, + { threshold: 0.5 } + ); + + observer.observe(loadMoreRef.current); + return () => observer.disconnect(); + }, [enabled, hasMore, isLoadingMore, onLoadMore]); + + return loadMoreRef; +} diff --git a/application/shared-webapp/ui/hooks/useKeyboardNavigation.ts b/application/shared-webapp/ui/hooks/useKeyboardNavigation.ts new file mode 100644 index 000000000..6b0c418c8 --- /dev/null +++ b/application/shared-webapp/ui/hooks/useKeyboardNavigation.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +export function useKeyboardNavigation() { + const [isKeyboardNavigation, setIsKeyboardNavigation] = useState(false); + + useEffect(() => { + const handleKeyDown = () => setIsKeyboardNavigation(true); + const handlePointerInput = () => setIsKeyboardNavigation(false); + + // Use capture phase to ensure we set the flag before any click handlers + document.addEventListener("keydown", handleKeyDown, true); + document.addEventListener("mousedown", handlePointerInput, true); + document.addEventListener("pointerdown", handlePointerInput, true); + + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + document.removeEventListener("mousedown", handlePointerInput, true); + document.removeEventListener("pointerdown", handlePointerInput, true); + }; + }, []); + + return isKeyboardNavigation; +} diff --git a/application/shared-webapp/ui/hooks/useViewportResize.ts b/application/shared-webapp/ui/hooks/useViewportResize.ts new file mode 100644 index 000000000..38f363254 --- /dev/null +++ b/application/shared-webapp/ui/hooks/useViewportResize.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; +import { isMobileViewport } from "../utils/responsive"; + +export function useViewportResize() { + const [isMobile, setIsMobile] = useState(isMobileViewport()); + + useEffect(() => { + const handleResize = () => { + setIsMobile(isMobileViewport()); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return isMobile; +} diff --git a/application/shared-webapp/ui/utils/responsive.ts b/application/shared-webapp/ui/utils/responsive.ts index 879fd60eb..f32b93122 100644 --- a/application/shared-webapp/ui/utils/responsive.ts +++ b/application/shared-webapp/ui/utils/responsive.ts @@ -29,3 +29,29 @@ export const SIDE_MENU_DEFAULT_WIDTH = 288; export function isTouchDevice(): boolean { return "ontouchstart" in window || navigator.maxTouchPoints > 0; } + +// Viewport detection helpers - based on Tailwind breakpoints +// Mobile: below 640px (sm) +export function isMobileViewport(): boolean { + return !window.matchMedia(MEDIA_QUERIES.sm).matches; +} + +// Small viewport and up: 640px+ (sm) +export function isSmallViewportOrLarger(): boolean { + return window.matchMedia(MEDIA_QUERIES.sm).matches; +} + +// Medium viewport and up: 768px+ (md) +export function isMediumViewportOrLarger(): boolean { + return window.matchMedia(MEDIA_QUERIES.md).matches; +} + +// Large viewport and up: 1024px+ (lg) +export function isLargeViewportOrLarger(): boolean { + return window.matchMedia(MEDIA_QUERIES.lg).matches; +} + +// Extra large viewport and up: 1280px+ (xl) +export function isExtraLargeViewportOrLarger(): boolean { + return window.matchMedia(MEDIA_QUERIES.xl).matches; +} From a9721a78e926cb509af28517413eb0b9af7928c3 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 27 Jul 2025 13:39:10 +0200 Subject: [PATCH 30/37] Make user table bounce on scroll for mobile screens --- .../WebApp/routes/admin/users/-components/UserTable.tsx | 4 ++-- application/shared-webapp/ui/components/AppLayout.tsx | 6 +----- application/shared-webapp/ui/tailwind.css | 2 -- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx index 61f947bb3..3e7a0afc4 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -213,7 +213,7 @@ export function UserTable({ return ( <> -
+
div]:h-[calc(100vh-14rem)]" : ""} + className={isMobile ? "[&>div>div>div]:-webkit-overflow-scrolling-touch" : ""} > Date: Sun, 27 Jul 2025 14:34:51 +0200 Subject: [PATCH 31/37] Align theme selector menu to the right on mobile --- .../WebApp/federated-modules/common/ThemeModeSelector.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/application/account-management/WebApp/federated-modules/common/ThemeModeSelector.tsx b/application/account-management/WebApp/federated-modules/common/ThemeModeSelector.tsx index 529f77ed4..6fd4cb433 100644 --- a/application/account-management/WebApp/federated-modules/common/ThemeModeSelector.tsx +++ b/application/account-management/WebApp/federated-modules/common/ThemeModeSelector.tsx @@ -175,7 +175,11 @@ export default function ThemeModeSelector({ )} - +
{window.matchMedia("(prefers-color-scheme: dark)").matches ? ( From bca6f45f326eb1b66127f7fe1c26d98fecd97fb5 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 27 Jul 2025 14:50:46 +0200 Subject: [PATCH 32/37] Add directional scroll locking for tables on touch devices --- .../shared-webapp/ui/components/Table.tsx | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/application/shared-webapp/ui/components/Table.tsx b/application/shared-webapp/ui/components/Table.tsx index 16c8d9488..fc118f841 100644 --- a/application/shared-webapp/ui/components/Table.tsx +++ b/application/shared-webapp/ui/components/Table.tsx @@ -3,6 +3,7 @@ * ref: https://ui.shadcn.com/docs/components/table */ import { ArrowUp } from "lucide-react"; +import { useEffect, useRef } from "react"; import { Cell as AriaCell, type CellProps as AriaCellProps, @@ -23,6 +24,7 @@ import { useTableOptions } from "react-aria-components"; import { tv } from "tailwind-variants"; +import { isTouchDevice } from "../utils/responsive"; import { Checkbox } from "./Checkbox"; import { focusRing } from "./focusRing"; import { composeTailwindRenderProps } from "./utils"; @@ -30,11 +32,89 @@ import { composeTailwindRenderProps } from "./utils"; export { TableBody, useContextProps } from "react-aria-components"; export function Table(props: Readonly) { + const scrollContainerRef = useRef(null); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + let scrollDirection: "horizontal" | "vertical" | null = null; + let startX = 0; + let startY = 0; + let startScrollLeft = 0; + let startScrollTop = 0; + let _touchStartTime = 0; + + const handleTouchStart = (e: TouchEvent) => { + const touch = e.touches[0]; + startX = touch.clientX; + startY = touch.clientY; + startScrollLeft = container.scrollLeft; + startScrollTop = container.scrollTop; + _touchStartTime = Date.now(); + scrollDirection = null; + }; + + const handleTouchMove = (e: TouchEvent) => { + if (!e.touches[0]) { + return; + } + + const touch = e.touches[0]; + const deltaX = touch.clientX - startX; + const deltaY = touch.clientY - startY; + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); + + // Determine scroll direction on first significant movement + if (!scrollDirection && (absDeltaX > 5 || absDeltaY > 5)) { + scrollDirection = absDeltaX > absDeltaY ? "horizontal" : "vertical"; + } + + // Lock scrolling to the determined direction + if (scrollDirection === "horizontal") { + e.preventDefault(); + container.scrollLeft = startScrollLeft - deltaX; + } else if (scrollDirection === "vertical") { + // Check if we can scroll vertically + const canScrollUp = container.scrollTop > 0; + const canScrollDown = container.scrollTop < container.scrollHeight - container.clientHeight; + + if ((deltaY > 0 && canScrollUp) || (deltaY < 0 && canScrollDown)) { + e.preventDefault(); + container.scrollTop = startScrollTop - deltaY; + } + } + }; + + const handleTouchEnd = () => { + scrollDirection = null; + }; + + // Only add touch event listeners on touch devices + if (isTouchDevice()) { + container.addEventListener("touchstart", handleTouchStart, { passive: false }); + container.addEventListener("touchmove", handleTouchMove, { passive: false }); + container.addEventListener("touchend", handleTouchEnd); + } + + return () => { + if (isTouchDevice()) { + container.removeEventListener("touchstart", handleTouchStart); + container.removeEventListener("touchmove", handleTouchMove); + container.removeEventListener("touchend", handleTouchEnd); + } + }; + }, []); + return (
From 680a040cc9bef4963161ca56fe4bfca72ac36d14 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 27 Jul 2025 16:13:47 +0200 Subject: [PATCH 33/37] Disable horizontal scrolling on mobile tables when side panel is open --- .../admin/users/-components/UserTable.tsx | 5 +- .../WebApp/routes/admin/users/index.tsx | 1 + .../shared-webapp/ui/components/Table.tsx | 90 +++---------------- .../shared-webapp/ui/hooks/useAxisLock.ts | 80 +++++++++++++++++ 4 files changed, 96 insertions(+), 80 deletions(-) create mode 100644 application/shared-webapp/ui/hooks/useAxisLock.ts diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx index 3e7a0afc4..8ed038b28 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -32,6 +32,7 @@ interface UserTableProps { onDeleteUser: (user: UserDetails) => void; onChangeRole: (user: UserDetails) => void; onUsersLoaded?: (users: UserDetails[]) => void; + isProfileOpen?: boolean; } // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Component handles complex table interactions including sorting, selection, pagination and infinite scroll @@ -41,7 +42,8 @@ export function UserTable({ onViewProfile, onDeleteUser, onChangeRole, - onUsersLoaded + onUsersLoaded, + isProfileOpen }: Readonly) { const navigate = useNavigate(); const { search, userRole, userStatus, startDate, endDate, orderBy, sortOrder, pageOffset } = useSearch({ @@ -224,6 +226,7 @@ export function UserTable({ onSortChange={handleSortChange} aria-label={t`Users`} className={isMobile ? "[&>div>div>div]:-webkit-overflow-scrolling-touch" : ""} + disableHorizontalScroll={isProfileOpen} >
diff --git a/application/shared-webapp/ui/components/Table.tsx b/application/shared-webapp/ui/components/Table.tsx index fc118f841..99ad3c726 100644 --- a/application/shared-webapp/ui/components/Table.tsx +++ b/application/shared-webapp/ui/components/Table.tsx @@ -3,7 +3,6 @@ * ref: https://ui.shadcn.com/docs/components/table */ import { ArrowUp } from "lucide-react"; -import { useEffect, useRef } from "react"; import { Cell as AriaCell, type CellProps as AriaCellProps, @@ -24,6 +23,7 @@ import { useTableOptions } from "react-aria-components"; import { tv } from "tailwind-variants"; +import { useAxisLock } from "../hooks/useAxisLock"; import { isTouchDevice } from "../utils/responsive"; import { Checkbox } from "./Checkbox"; import { focusRing } from "./focusRing"; @@ -31,90 +31,22 @@ import { composeTailwindRenderProps } from "./utils"; export { TableBody, useContextProps } from "react-aria-components"; -export function Table(props: Readonly) { - const scrollContainerRef = useRef(null); - - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) { - return; - } - - let scrollDirection: "horizontal" | "vertical" | null = null; - let startX = 0; - let startY = 0; - let startScrollLeft = 0; - let startScrollTop = 0; - let _touchStartTime = 0; - - const handleTouchStart = (e: TouchEvent) => { - const touch = e.touches[0]; - startX = touch.clientX; - startY = touch.clientY; - startScrollLeft = container.scrollLeft; - startScrollTop = container.scrollTop; - _touchStartTime = Date.now(); - scrollDirection = null; - }; - - const handleTouchMove = (e: TouchEvent) => { - if (!e.touches[0]) { - return; - } - - const touch = e.touches[0]; - const deltaX = touch.clientX - startX; - const deltaY = touch.clientY - startY; - const absDeltaX = Math.abs(deltaX); - const absDeltaY = Math.abs(deltaY); - - // Determine scroll direction on first significant movement - if (!scrollDirection && (absDeltaX > 5 || absDeltaY > 5)) { - scrollDirection = absDeltaX > absDeltaY ? "horizontal" : "vertical"; - } - - // Lock scrolling to the determined direction - if (scrollDirection === "horizontal") { - e.preventDefault(); - container.scrollLeft = startScrollLeft - deltaX; - } else if (scrollDirection === "vertical") { - // Check if we can scroll vertically - const canScrollUp = container.scrollTop > 0; - const canScrollDown = container.scrollTop < container.scrollHeight - container.clientHeight; - - if ((deltaY > 0 && canScrollUp) || (deltaY < 0 && canScrollDown)) { - e.preventDefault(); - container.scrollTop = startScrollTop - deltaY; - } - } - }; - - const handleTouchEnd = () => { - scrollDirection = null; - }; - - // Only add touch event listeners on touch devices - if (isTouchDevice()) { - container.addEventListener("touchstart", handleTouchStart, { passive: false }); - container.addEventListener("touchmove", handleTouchMove, { passive: false }); - container.addEventListener("touchend", handleTouchEnd); - } +interface ExtendedTableProps extends TableProps { + disableHorizontalScroll?: boolean; +} - return () => { - if (isTouchDevice()) { - container.removeEventListener("touchstart", handleTouchStart); - container.removeEventListener("touchmove", handleTouchMove); - container.removeEventListener("touchend", handleTouchEnd); - } - }; - }, []); +export function Table({ disableHorizontalScroll, ...props }: Readonly) { + const scrollRef = useAxisLock(); + const isMobile = isTouchDevice(); return (
diff --git a/application/shared-webapp/ui/hooks/useAxisLock.ts b/application/shared-webapp/ui/hooks/useAxisLock.ts new file mode 100644 index 000000000..0fd44c6b5 --- /dev/null +++ b/application/shared-webapp/ui/hooks/useAxisLock.ts @@ -0,0 +1,80 @@ +import { useEffect, useRef } from "react"; + +export function useAxisLock() { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + + let startX = 0; + let startY = 0; + let isLocked = false; + const threshold = 10; // pixels before locking + + const handlePointerDown = (e: PointerEvent) => { + if (e.pointerType !== "touch") { + return; + } + + startX = e.clientX; + startY = e.clientY; + isLocked = false; + + // Start with both directions allowed + element.style.touchAction = "pan-x pan-y"; + }; + + const handlePointerMove = (e: PointerEvent) => { + if (e.pointerType !== "touch" || isLocked) { + return; + } + + const deltaX = Math.abs(e.clientX - startX); + const deltaY = Math.abs(e.clientY - startY); + + // Lock to axis after threshold + if (deltaX > threshold || deltaY > threshold) { + if (deltaX > deltaY) { + // Lock to horizontal + element.style.touchAction = "pan-x"; + } else { + // Lock to vertical + element.style.touchAction = "pan-y"; + } + isLocked = true; + } + }; + + const handlePointerUp = () => { + // Reset for next gesture + element.style.touchAction = "pan-x pan-y"; + isLocked = false; + }; + + // Set initial styles + element.style.touchAction = "pan-x pan-y"; + element.style.overscrollBehavior = "contain"; + + // Use pointer events for better performance + element.addEventListener("pointerdown", handlePointerDown); + element.addEventListener("pointermove", handlePointerMove); + element.addEventListener("pointerup", handlePointerUp); + element.addEventListener("pointercancel", handlePointerUp); + + return () => { + element.removeEventListener("pointerdown", handlePointerDown); + element.removeEventListener("pointermove", handlePointerMove); + element.removeEventListener("pointerup", handlePointerUp); + element.removeEventListener("pointercancel", handlePointerUp); + + // Clean up styles + element.style.touchAction = ""; + element.style.overscrollBehavior = ""; + }; + }, []); + + return ref; +} From c5cd9f04f5bc2b21a992559973366c1a6d0b2552 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 27 Jul 2025 16:27:34 +0200 Subject: [PATCH 34/37] Fix mobile menu click-through issue by adding touch event handling and delays --- .../federated-modules/sideMenu/MobileMenu.tsx | 64 ++++++++----- .../shared-webapp/ui/components/SideMenu.tsx | 90 ++++++++++++------- 2 files changed, 98 insertions(+), 56 deletions(-) diff --git a/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx b/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx index bf8713e0d..74227f2db 100644 --- a/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx +++ b/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx @@ -49,14 +49,22 @@ function MobileMenuHeader({ onEditProfile }: { onEditProfile: () => void }) {
@@ -120,7 +136,7 @@ function MobileMenuHeader({ onEditProfile }: { onEditProfile: () => void }) {