@salesforce/ui-bundle-template-app-react-sample-b2x 12.6.3 → 12.7.0-canary.1016.23.1.g31dc6b7a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CHANGELOG.md +9 -0
- package/dist/LICENSE.txt +82 -0
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/package.json +6 -6
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/api/graphql-operations-types.ts +1 -2
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/appLayout.tsx +5 -4
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/layout/VerticalNav.tsx +60 -27
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/maintenanceRequests/MaintenanceRequestList.tsx +18 -17
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/properties/PropertyListingCard.tsx +2 -2
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/properties/PropertyMap.tsx +10 -2
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/properties/PropertySearchFilters.tsx +1 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/ui/button.tsx +2 -2
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/ui/checkbox.tsx +1 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/ui/input.tsx +1 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/ui/select.tsx +1 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/context/TenantAccessContext.tsx +23 -6
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/forms/auth-form.tsx +13 -2
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/hooks/form.tsx +12 -3
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/layout/centered-page-layout.tsx +8 -40
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/layouts/TenantRoute.tsx +28 -4
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/layouts/privateRouteLayout.tsx +1 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/menu/AuthMenu.tsx +43 -5
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/pages/ChangePassword.tsx +1 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/pages/ForgotPassword.tsx +8 -4
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/pages/Login.tsx +1 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/pages/Register.tsx +1 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/pages/ResetPassword.tsx +2 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/sessionTimeout/SessionTimeoutValidator.tsx +30 -7
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/object-search/api/objectSearchService.ts +5 -10
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/object-search/components/PaginationControls.tsx +38 -3
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/hooks/useReanchorReadingCursor.ts +54 -0
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Application.tsx +30 -8
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Contact.tsx +95 -48
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Home.tsx +34 -23
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Maintenance.tsx +5 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/PropertyDetails.tsx +48 -16
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/PropertySearch.tsx +15 -5
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/styles/global.css +2 -2
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/tsconfig.tsbuildinfo +1 -1
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/dist/scripts/network-senders.mjs +84 -0
- package/dist/scripts/org-setup-xml.mjs +11 -6
- package/dist/scripts/org-setup.mjs +13 -5
- package/package.json +1 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.,
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* For full license text, see the LICENSE.txt file
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
8
|
import { cn } from "../../../lib/utils";
|
|
3
9
|
|
|
4
|
-
/**
|
|
5
|
-
* Variant styles for the content container's maximum width.
|
|
6
|
-
* Controls the maximum width of the inner content area within the page layout.
|
|
7
|
-
*/
|
|
8
10
|
const contentContainerVariants = cva("w-full", {
|
|
9
11
|
variants: {
|
|
10
12
|
contentMaxWidth: {
|
|
@@ -18,48 +20,14 @@ const contentContainerVariants = cva("w-full", {
|
|
|
18
20
|
},
|
|
19
21
|
});
|
|
20
22
|
|
|
21
|
-
/**
|
|
22
|
-
* Props for the CenteredPageLayout component.
|
|
23
|
-
*/
|
|
24
23
|
interface CenteredPageLayoutProps
|
|
25
24
|
extends React.ComponentProps<"div">, VariantProps<typeof contentContainerVariants> {
|
|
26
|
-
/** The content to be displayed within the page layout */
|
|
27
25
|
children: React.ReactNode;
|
|
28
|
-
/**
|
|
29
|
-
* Maximum width of the content container.
|
|
30
|
-
* @default "sm"
|
|
31
|
-
*/
|
|
32
26
|
contentMaxWidth?: "sm" | "md" | "lg";
|
|
33
|
-
/**
|
|
34
|
-
* Optional page title. If provided, will render a <title> component that React will place in the document head.
|
|
35
|
-
*/
|
|
36
27
|
title?: string;
|
|
37
|
-
/**
|
|
38
|
-
* When true, content is aligned to the top instead of being vertically centered.
|
|
39
|
-
* @default true
|
|
40
|
-
*/
|
|
41
28
|
topAligned?: boolean;
|
|
42
29
|
}
|
|
43
30
|
|
|
44
|
-
/**
|
|
45
|
-
* CenteredPageLayout component that provides consistent page structure and spacing.
|
|
46
|
-
*
|
|
47
|
-
* This component creates a full-viewport-height container that centers its content
|
|
48
|
-
* horizontally. By default, content is top-aligned; set `topAligned={false}` to
|
|
49
|
-
* vertically center instead. The inner content area has a configurable maximum width
|
|
50
|
-
* to prevent content from becoming too wide on large screens.
|
|
51
|
-
*
|
|
52
|
-
* @example
|
|
53
|
-
* ```tsx
|
|
54
|
-
* <CenteredPageLayout contentMaxWidth="md">
|
|
55
|
-
* <YourPageContent />
|
|
56
|
-
* </CenteredPageLayout>
|
|
57
|
-
*
|
|
58
|
-
* <CenteredPageLayout contentMaxWidth="md" topAligned={false}>
|
|
59
|
-
* <VerticallyCenteredContent />
|
|
60
|
-
* </CenteredPageLayout>
|
|
61
|
-
* ```
|
|
62
|
-
*/
|
|
63
31
|
export function CenteredPageLayout({
|
|
64
32
|
contentMaxWidth,
|
|
65
33
|
className,
|
|
@@ -71,7 +39,7 @@ export function CenteredPageLayout({
|
|
|
71
39
|
return (
|
|
72
40
|
<>
|
|
73
41
|
{title && <title>{title}</title>}
|
|
74
|
-
<
|
|
42
|
+
<div
|
|
75
43
|
className={cn(
|
|
76
44
|
"flex min-h-svh w-full justify-center p-6 md:p-10",
|
|
77
45
|
topAligned ? "items-start" : "items-center",
|
|
@@ -81,7 +49,7 @@ export function CenteredPageLayout({
|
|
|
81
49
|
{...props}
|
|
82
50
|
>
|
|
83
51
|
<div className={contentContainerVariants({ contentMaxWidth })}>{children}</div>
|
|
84
|
-
</
|
|
52
|
+
</div>
|
|
85
53
|
</>
|
|
86
54
|
);
|
|
87
55
|
}
|
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
import { Navigate, Outlet, useLocation } from "react-router";
|
|
2
2
|
import { useAuth } from "@/features/authentication/context/AuthContext";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AUTH_REDIRECT_PARAM,
|
|
5
|
+
ROUTES,
|
|
6
|
+
} from "@/features/authentication/authenticationConfig";
|
|
4
7
|
import { useTenantAccess } from "@/context/TenantAccessContext";
|
|
5
8
|
|
|
6
9
|
export default function TenantRoute() {
|
|
7
10
|
const location = useLocation();
|
|
8
11
|
const { isAuthenticated, loading } = useAuth();
|
|
9
|
-
const { hasTenantRecord, loading: tenantLoading } = useTenantAccess();
|
|
12
|
+
const { hasTenantRecord, loading: tenantLoading, error, retry } = useTenantAccess();
|
|
10
13
|
|
|
11
|
-
if (loading
|
|
14
|
+
if (loading) return <p role="status">Checking sign-in…</p>;
|
|
12
15
|
|
|
13
16
|
if (!isAuthenticated) {
|
|
14
|
-
|
|
17
|
+
const search = new URLSearchParams({
|
|
18
|
+
[AUTH_REDIRECT_PARAM]: location.pathname + location.search,
|
|
19
|
+
});
|
|
20
|
+
return <Navigate to={{ pathname: ROUTES.LOGIN.PATH, search: search.toString() }} replace />;
|
|
15
21
|
}
|
|
16
22
|
|
|
23
|
+
if (tenantLoading) return <p role="status">Checking tenant access…</p>;
|
|
24
|
+
if (error)
|
|
25
|
+
return (
|
|
26
|
+
<div className="space-y-4">
|
|
27
|
+
<h1 className="text-xl font-semibold">Tenant access unavailable</h1>
|
|
28
|
+
<p role="alert" className="text-destructive">
|
|
29
|
+
{error}
|
|
30
|
+
</p>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
onClick={retry}
|
|
34
|
+
className="rounded-md border px-4 py-2 focus-visible:outline-2 focus-visible:outline-offset-2"
|
|
35
|
+
>
|
|
36
|
+
Try again
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
|
|
17
41
|
if (!hasTenantRecord) {
|
|
18
42
|
return <Navigate to="/" replace />;
|
|
19
43
|
}
|
|
@@ -12,7 +12,7 @@ export default function PrivateRoute() {
|
|
|
12
12
|
const { isAuthenticated, loading } = useAuth();
|
|
13
13
|
const location = useLocation();
|
|
14
14
|
|
|
15
|
-
if (loading) return
|
|
15
|
+
if (loading) return <p role="status">Checking sign-in…</p>;
|
|
16
16
|
|
|
17
17
|
if (!isAuthenticated) {
|
|
18
18
|
const searchParams = new URLSearchParams();
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2024, Salesforce, Inc.
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useId, useRef } from "react";
|
|
1
8
|
import { CircleUser, LogIn, LogOut, UserPen, UserPlus } from "lucide-react";
|
|
2
|
-
import { Link } from "react-router";
|
|
9
|
+
import { Link, useLocation } from "react-router";
|
|
3
10
|
import { useAuth } from "../context/AuthContext";
|
|
4
11
|
import { ROUTES } from "../authenticationConfig";
|
|
5
12
|
import { Button } from "../../../components/ui/button";
|
|
@@ -31,6 +38,13 @@ export interface AuthMenuProps {
|
|
|
31
38
|
|
|
32
39
|
export function AuthMenu({ trigger, guestContent, menuItems, className }: AuthMenuProps) {
|
|
33
40
|
const { user, isAuthenticated, loading, logout } = useAuth();
|
|
41
|
+
const { pathname } = useLocation();
|
|
42
|
+
const userLabelId = useId();
|
|
43
|
+
const openedPathname = useRef(pathname);
|
|
44
|
+
const currentPathname = useRef(pathname);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
currentPathname.current = pathname;
|
|
47
|
+
}, [pathname]);
|
|
34
48
|
|
|
35
49
|
if (loading) {
|
|
36
50
|
return <Skeleton className="size-8 rounded-full" />;
|
|
@@ -49,14 +63,38 @@ export function AuthMenu({ trigger, guestContent, menuItems, className }: AuthMe
|
|
|
49
63
|
const triggerNode = (isAuthenticated && user && trigger?.(user)) || defaultTrigger;
|
|
50
64
|
|
|
51
65
|
return (
|
|
52
|
-
<DropdownMenu
|
|
66
|
+
<DropdownMenu
|
|
67
|
+
onOpenChange={(open) => {
|
|
68
|
+
if (open) openedPathname.current = pathname;
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
53
71
|
<DropdownMenuTrigger asChild>{triggerNode}</DropdownMenuTrigger>
|
|
54
72
|
|
|
55
|
-
<DropdownMenuContent
|
|
73
|
+
<DropdownMenuContent
|
|
74
|
+
align="end"
|
|
75
|
+
aria-describedby={isAuthenticated ? userLabelId : undefined}
|
|
76
|
+
className={className ?? "w-48"}
|
|
77
|
+
onCloseAutoFocus={(event) => {
|
|
78
|
+
if (openedPathname.current === currentPathname.current) return;
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
// Recover page focus intercepted by the closing menu trap.
|
|
81
|
+
const active = document.activeElement;
|
|
82
|
+
if (active && active !== document.body && active.isConnected) return;
|
|
83
|
+
const target =
|
|
84
|
+
document.querySelector<HTMLElement>("main h1, [role='main'] h1") ??
|
|
85
|
+
document.querySelector<HTMLElement>("main, [role='main']");
|
|
86
|
+
if (target) {
|
|
87
|
+
if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
|
|
88
|
+
target.focus();
|
|
89
|
+
}
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
56
92
|
{isAuthenticated ? (
|
|
57
93
|
<>
|
|
58
|
-
<DropdownMenuLabel className="truncate">
|
|
59
|
-
|
|
94
|
+
<DropdownMenuLabel id={userLabelId} className="truncate" aria-hidden="true">
|
|
95
|
+
{user?.name}
|
|
96
|
+
</DropdownMenuLabel>
|
|
97
|
+
<DropdownMenuSeparator aria-hidden="true" />
|
|
60
98
|
<DropdownMenuItem asChild>
|
|
61
99
|
<Link to={ROUTES.PROFILE.PATH}>
|
|
62
100
|
<UserPen className="size-4" />
|
|
@@ -4,11 +4,15 @@ import { CenteredPageLayout } from "../layout/centered-page-layout";
|
|
|
4
4
|
import { AuthForm } from "../forms/auth-form";
|
|
5
5
|
import { useAppForm } from "../hooks/form";
|
|
6
6
|
import { createDataSDK } from "@salesforce/platform-sdk";
|
|
7
|
-
import { ROUTES
|
|
7
|
+
import { ROUTES } from "../authenticationConfig";
|
|
8
8
|
import { ApiError, handleApiResponse } from "../utils/helpers";
|
|
9
9
|
|
|
10
10
|
const forgotPasswordSchema = z.object({
|
|
11
|
-
username: z
|
|
11
|
+
username: z
|
|
12
|
+
.string()
|
|
13
|
+
.trim()
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.email("Enter your username in email format, for example asmith@example.com"),
|
|
12
16
|
});
|
|
13
17
|
|
|
14
18
|
export default function ForgotPassword() {
|
|
@@ -42,7 +46,7 @@ export default function ForgotPassword() {
|
|
|
42
46
|
err.errors.length === 1 ? (
|
|
43
47
|
err.errors[0]
|
|
44
48
|
) : (
|
|
45
|
-
<ul>
|
|
49
|
+
<ul role="list">
|
|
46
50
|
{err.errors.map((e, i) => (
|
|
47
51
|
<li key={i}>{e}</li>
|
|
48
52
|
))}
|
|
@@ -75,7 +79,7 @@ export default function ForgotPassword() {
|
|
|
75
79
|
{(field) => (
|
|
76
80
|
<field.TextField
|
|
77
81
|
label="Username"
|
|
78
|
-
placeholder=
|
|
82
|
+
placeholder="asmith@example.com"
|
|
79
83
|
autoComplete="username"
|
|
80
84
|
disabled={success}
|
|
81
85
|
/>
|
|
@@ -45,7 +45,7 @@ export default function ResetPassword() {
|
|
|
45
45
|
err.errors.length === 1 ? (
|
|
46
46
|
err.errors[0]
|
|
47
47
|
) : (
|
|
48
|
-
<ul>
|
|
48
|
+
<ul role="list">
|
|
49
49
|
{err.errors.map((e, i) => (
|
|
50
50
|
<li key={i}>{e}</li>
|
|
51
51
|
))}
|
|
@@ -64,6 +64,7 @@ export default function ResetPassword() {
|
|
|
64
64
|
return (
|
|
65
65
|
<CenteredPageLayout title={ROUTES.RESET_PASSWORD.TITLE}>
|
|
66
66
|
<CardLayout title="Reset Password">
|
|
67
|
+
<h1 className="sr-only">Reset Password</h1>
|
|
67
68
|
<StatusAlert>
|
|
68
69
|
Reset token is invalid or expired.{" "}
|
|
69
70
|
<Link to={ROUTES.FORGOT_PASSWORD.PATH} className="underline underline-offset-4">
|
|
@@ -378,6 +378,7 @@ function SessionWarningModal({
|
|
|
378
378
|
onCountdownExpire,
|
|
379
379
|
}: SessionWarningModalProps) {
|
|
380
380
|
const continueButtonRef = useRef<HTMLButtonElement>(null);
|
|
381
|
+
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
381
382
|
|
|
382
383
|
// Countdown timer with accessibility
|
|
383
384
|
const timer = useCountdownTimer({
|
|
@@ -399,13 +400,6 @@ function SessionWarningModal({
|
|
|
399
400
|
start();
|
|
400
401
|
}, [isOpen, timeRemaining, start, stop, reset]);
|
|
401
402
|
|
|
402
|
-
// Focus the continue button when dialog opens
|
|
403
|
-
useEffect(() => {
|
|
404
|
-
if (isOpen) {
|
|
405
|
-
continueButtonRef.current?.focus();
|
|
406
|
-
}
|
|
407
|
-
}, [isOpen]);
|
|
408
|
-
|
|
409
403
|
// Handle "Continue Working" button click
|
|
410
404
|
const handleContinue = useCallback(() => {
|
|
411
405
|
stop();
|
|
@@ -421,6 +415,30 @@ function SessionWarningModal({
|
|
|
421
415
|
return (
|
|
422
416
|
<Dialog open={isOpen} onOpenChange={() => {}}>
|
|
423
417
|
<DialogContent
|
|
418
|
+
aria-labelledby="session-warning-title"
|
|
419
|
+
aria-describedby="session-warning-description"
|
|
420
|
+
onOpenAutoFocus={(event) => {
|
|
421
|
+
event.preventDefault();
|
|
422
|
+
previousFocusRef.current =
|
|
423
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
424
|
+
continueButtonRef.current?.focus();
|
|
425
|
+
}}
|
|
426
|
+
onCloseAutoFocus={(event) => {
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
const previous = previousFocusRef.current;
|
|
429
|
+
previousFocusRef.current = null;
|
|
430
|
+
if (previous?.isConnected && previous !== document.body) {
|
|
431
|
+
previous.focus();
|
|
432
|
+
if (document.activeElement === previous) return;
|
|
433
|
+
}
|
|
434
|
+
const target = document.querySelector<HTMLElement>("main, #main-content");
|
|
435
|
+
if (target) {
|
|
436
|
+
if (target.tabIndex < 0 && !target.hasAttribute("tabindex")) {
|
|
437
|
+
target.setAttribute("tabindex", "-1");
|
|
438
|
+
}
|
|
439
|
+
target.focus();
|
|
440
|
+
}
|
|
441
|
+
}}
|
|
424
442
|
showCloseButton={false}
|
|
425
443
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
|
426
444
|
onPointerDownOutside={(e) => e.preventDefault()}
|
|
@@ -578,6 +596,11 @@ export default function SessionTimeoutValidator({
|
|
|
578
596
|
*/
|
|
579
597
|
const handleDismissAlert = useCallback(() => {
|
|
580
598
|
setShowExpiredAlert(false);
|
|
599
|
+
const main = document.querySelector<HTMLElement>("main, #main-content");
|
|
600
|
+
if (main) {
|
|
601
|
+
if (!main.hasAttribute("tabindex")) main.setAttribute("tabindex", "-1");
|
|
602
|
+
main.focus();
|
|
603
|
+
}
|
|
581
604
|
}, []);
|
|
582
605
|
|
|
583
606
|
return (
|
|
@@ -29,11 +29,9 @@ export async function searchObjects<TResult, TQuery, TVariables>(
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const uiapi = (result.data as Record<string, unknown> | undefined)?.uiapi as
|
|
32
|
-
|
|
33
|
-
| undefined;
|
|
32
|
+
Record<string, unknown> | undefined;
|
|
34
33
|
const queryResult = (uiapi?.query as Record<string, unknown> | undefined)?.[objectName] as
|
|
35
|
-
|
|
|
36
|
-
| undefined;
|
|
34
|
+
TResult | undefined;
|
|
37
35
|
|
|
38
36
|
if (!queryResult) {
|
|
39
37
|
throw new Error(`No ${objectName} data returned`);
|
|
@@ -56,18 +54,15 @@ export async function fetchDistinctValues<TQuery>(
|
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
const uiapi = (result.data as Record<string, unknown> | undefined)?.uiapi as
|
|
59
|
-
|
|
60
|
-
| undefined;
|
|
57
|
+
Record<string, unknown> | undefined;
|
|
61
58
|
const aggregate = (uiapi?.aggregate as Record<string, unknown> | undefined)?.[objectName] as
|
|
62
|
-
|
|
63
|
-
| undefined;
|
|
59
|
+
{ edges?: Array<{ node?: { aggregate?: Record<string, unknown> } }> } | undefined;
|
|
64
60
|
|
|
65
61
|
const edges = aggregate?.edges ?? [];
|
|
66
62
|
return edges
|
|
67
63
|
.map((edge) => {
|
|
68
64
|
const field = edge?.node?.aggregate?.[fieldName] as
|
|
69
|
-
|
|
70
|
-
| undefined;
|
|
65
|
+
{ value?: string | null; displayValue?: string | null; label?: string | null } | undefined;
|
|
71
66
|
const value = field?.value;
|
|
72
67
|
if (!value) return null;
|
|
73
68
|
return { value, label: field.label ?? field.displayValue ?? value };
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
SelectValue,
|
|
14
14
|
} from "../../../components/ui/select";
|
|
15
15
|
import { Label } from "../../../components/ui/label";
|
|
16
|
+
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
|
16
17
|
|
|
17
18
|
interface PaginationControlsProps {
|
|
18
19
|
pageIndex: number;
|
|
@@ -37,7 +38,9 @@ export default function PaginationControls({
|
|
|
37
38
|
onPageSizeChange,
|
|
38
39
|
disabled = false,
|
|
39
40
|
}: PaginationControlsProps) {
|
|
41
|
+
const [pageSizeOpen, setPageSizeOpen] = useState(false);
|
|
40
42
|
const handlePageSizeChange = (newValue: string) => {
|
|
43
|
+
if (disabled) return;
|
|
41
44
|
const newSize = parseInt(newValue, 10);
|
|
42
45
|
if (!isNaN(newSize) && newSize !== pageSize) {
|
|
43
46
|
onPageSizeChange(newSize);
|
|
@@ -46,9 +49,33 @@ export default function PaginationControls({
|
|
|
46
49
|
const currentPage = pageIndex + 1;
|
|
47
50
|
const prevDisabled = disabled || !hasPreviousPage;
|
|
48
51
|
const nextDisabled = disabled || !hasNextPage;
|
|
52
|
+
const navRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (disabled) {
|
|
56
|
+
setPageSizeOpen(false);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const buttons = navRef.current?.querySelectorAll<HTMLElement>('[role="button"]');
|
|
60
|
+
if (!buttons || buttons.length === 0) return;
|
|
61
|
+
const first = buttons[0];
|
|
62
|
+
const last = buttons[buttons.length - 1];
|
|
63
|
+
if (nextDisabled && !prevDisabled && document.activeElement === last) first.focus();
|
|
64
|
+
else if (prevDisabled && !nextDisabled && document.activeElement === first) last.focus();
|
|
65
|
+
}, [disabled, prevDisabled, nextDisabled]);
|
|
66
|
+
|
|
67
|
+
const handleActivationKey = (action: () => void) => (event: KeyboardEvent) => {
|
|
68
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
action();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
49
73
|
|
|
50
74
|
return (
|
|
51
|
-
<div
|
|
75
|
+
<div
|
|
76
|
+
ref={navRef}
|
|
77
|
+
className="w-full grid grid-cols-1 sm:grid-cols-2 items-center justify-center gap-4 py-2"
|
|
78
|
+
>
|
|
52
79
|
<div
|
|
53
80
|
className="flex justify-center sm:justify-start items-center gap-2 shrink-0 row-2 sm:row-1"
|
|
54
81
|
role="group"
|
|
@@ -60,11 +87,13 @@ export default function PaginationControls({
|
|
|
60
87
|
<Select
|
|
61
88
|
value={pageSize.toString()}
|
|
62
89
|
onValueChange={handlePageSizeChange}
|
|
63
|
-
|
|
90
|
+
open={pageSizeOpen}
|
|
91
|
+
onOpenChange={(open) => setPageSizeOpen(!disabled && open)}
|
|
64
92
|
>
|
|
65
93
|
<SelectTrigger
|
|
66
94
|
id="page-size-select"
|
|
67
|
-
className=
|
|
95
|
+
className={`w-16 ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
|
96
|
+
aria-disabled={disabled || undefined}
|
|
68
97
|
aria-label="Select number of results per page"
|
|
69
98
|
>
|
|
70
99
|
<SelectValue />
|
|
@@ -82,7 +111,10 @@ export default function PaginationControls({
|
|
|
82
111
|
<PaginationContent>
|
|
83
112
|
<PaginationItem>
|
|
84
113
|
<PaginationPrevious
|
|
114
|
+
role="button"
|
|
115
|
+
tabIndex={prevDisabled ? -1 : 0}
|
|
85
116
|
onClick={prevDisabled ? undefined : onPreviousPage}
|
|
117
|
+
onKeyDown={prevDisabled ? undefined : handleActivationKey(onPreviousPage)}
|
|
86
118
|
aria-disabled={prevDisabled}
|
|
87
119
|
className={prevDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
88
120
|
/>
|
|
@@ -97,7 +129,10 @@ export default function PaginationControls({
|
|
|
97
129
|
</PaginationItem>
|
|
98
130
|
<PaginationItem>
|
|
99
131
|
<PaginationNext
|
|
132
|
+
role="button"
|
|
133
|
+
tabIndex={nextDisabled ? -1 : 0}
|
|
100
134
|
onClick={nextDisabled ? undefined : onNextPage}
|
|
135
|
+
onKeyDown={nextDisabled ? undefined : handleActivationKey(onNextPage)}
|
|
101
136
|
aria-disabled={nextDisabled}
|
|
102
137
|
className={nextDisabled ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
|
103
138
|
/>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026, Salesforce, Inc.
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useRef } from "react";
|
|
7
|
+
|
|
8
|
+
function isTextEntry(el: HTMLElement): boolean {
|
|
9
|
+
if (el.isContentEditable) return true;
|
|
10
|
+
if (el.tagName === "TEXTAREA") return true;
|
|
11
|
+
if (el.tagName !== "INPUT") return false;
|
|
12
|
+
const type = (el.getAttribute("type") ?? "text").toLowerCase();
|
|
13
|
+
return !["button", "submit", "reset", "checkbox", "radio", "range", "color", "file"].includes(
|
|
14
|
+
type,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Reanchors the reading cursor after reloads while preserving newer focus and text entry. */
|
|
19
|
+
export function useReanchorReadingCursor(loading: boolean) {
|
|
20
|
+
const previousLoading = useRef(loading);
|
|
21
|
+
const focusedOnReloadStart = useRef<HTMLElement | null>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const started = !previousLoading.current && loading;
|
|
25
|
+
const settled = previousLoading.current && !loading;
|
|
26
|
+
previousLoading.current = loading;
|
|
27
|
+
|
|
28
|
+
if (started) {
|
|
29
|
+
const active = document.activeElement;
|
|
30
|
+
focusedOnReloadStart.current =
|
|
31
|
+
active instanceof HTMLElement && active !== document.body && !isTextEntry(active)
|
|
32
|
+
? active
|
|
33
|
+
: null;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!settled) return;
|
|
37
|
+
|
|
38
|
+
const target = focusedOnReloadStart.current;
|
|
39
|
+
focusedOnReloadStart.current = null;
|
|
40
|
+
if (!target || !target.isConnected) return;
|
|
41
|
+
const active = document.activeElement;
|
|
42
|
+
if (active !== target && active !== document.body) return;
|
|
43
|
+
|
|
44
|
+
const raf = requestAnimationFrame(() => {
|
|
45
|
+
if (!target.isConnected) return;
|
|
46
|
+
const current = document.activeElement;
|
|
47
|
+
if (current !== target && current !== document.body) return;
|
|
48
|
+
// VoiceOver tracks the blur/refocus transition after the results render.
|
|
49
|
+
if (document.activeElement === target) target.blur();
|
|
50
|
+
target.focus();
|
|
51
|
+
});
|
|
52
|
+
return () => cancelAnimationFrame(raf);
|
|
53
|
+
}, [loading]);
|
|
54
|
+
}
|
|
@@ -144,10 +144,18 @@ export default function Application() {
|
|
|
144
144
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
145
145
|
const [submittedName, setSubmittedName] = useState<string | null>(null);
|
|
146
146
|
const successHeadingRef = useRef<HTMLHeadingElement>(null);
|
|
147
|
+
const formHeadingRef = useRef<HTMLHeadingElement>(null);
|
|
148
|
+
const returnToForm = useRef(false);
|
|
147
149
|
|
|
148
150
|
useEffect(() => {
|
|
149
151
|
if (submittedName) successHeadingRef.current?.focus();
|
|
150
|
-
|
|
152
|
+
else if (!propertyId && !loading && returnToForm.current) {
|
|
153
|
+
returnToForm.current = false;
|
|
154
|
+
if (document.activeElement === document.body || document.activeElement?.matches("main")) {
|
|
155
|
+
formHeadingRef.current?.focus();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}, [submittedName, loading, propertyId]);
|
|
151
159
|
|
|
152
160
|
const handleSubmit = useCallback(
|
|
153
161
|
async (e: SubmitEvent<HTMLFormElement>) => {
|
|
@@ -174,7 +182,7 @@ export default function Application() {
|
|
|
174
182
|
setSubmitting(false);
|
|
175
183
|
}
|
|
176
184
|
},
|
|
177
|
-
[propertyId, contactId, moveInDate, employment, references
|
|
185
|
+
[propertyId, contactId, moveInDate, employment, references],
|
|
178
186
|
);
|
|
179
187
|
|
|
180
188
|
if (loading) {
|
|
@@ -183,17 +191,18 @@ export default function Application() {
|
|
|
183
191
|
|
|
184
192
|
if (submittedName) {
|
|
185
193
|
return (
|
|
186
|
-
<div className="mx-auto max-w-[900px]"
|
|
194
|
+
<div className="mx-auto max-w-[900px]">
|
|
187
195
|
<h1 className="sr-only">Application submitted</h1>
|
|
188
196
|
<Card className="mb-6 rounded-2xl border border-border p-6 shadow-sm">
|
|
189
197
|
<h2
|
|
190
198
|
ref={successHeadingRef}
|
|
191
199
|
tabIndex={-1}
|
|
200
|
+
aria-describedby="application-success-description"
|
|
192
201
|
className="mb-2 text-2xl font-semibold text-foreground"
|
|
193
202
|
>
|
|
194
203
|
Application submitted
|
|
195
204
|
</h2>
|
|
196
|
-
<p className="text-sm text-muted-foreground">
|
|
205
|
+
<p id="application-success-description" className="text-sm text-muted-foreground">
|
|
197
206
|
Your application
|
|
198
207
|
{propertyName ? (
|
|
199
208
|
<>
|
|
@@ -216,9 +225,20 @@ export default function Application() {
|
|
|
216
225
|
<Button
|
|
217
226
|
asChild
|
|
218
227
|
size="sm"
|
|
219
|
-
className="rounded-xl bg-primary px-5 py-5 text-lg font-medium transition-colors duration-200 hover:bg-primary/
|
|
228
|
+
className="rounded-xl bg-primary px-5 py-5 text-lg font-medium transition-colors duration-200 [a]:hover:bg-primary/95"
|
|
220
229
|
>
|
|
221
|
-
<Link
|
|
230
|
+
<Link
|
|
231
|
+
to="/application"
|
|
232
|
+
onClick={() => {
|
|
233
|
+
returnToForm.current = true;
|
|
234
|
+
setSubmittedName(null);
|
|
235
|
+
setMoveInDate("");
|
|
236
|
+
setEmployment("");
|
|
237
|
+
setReferences("");
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
Submit another
|
|
241
|
+
</Link>
|
|
222
242
|
</Button>
|
|
223
243
|
</div>
|
|
224
244
|
</Card>
|
|
@@ -228,7 +248,9 @@ export default function Application() {
|
|
|
228
248
|
|
|
229
249
|
return (
|
|
230
250
|
<div className="mx-auto max-w-[900px]">
|
|
231
|
-
<h1 className="sr-only">
|
|
251
|
+
<h1 ref={formHeadingRef} tabIndex={-1} className="sr-only">
|
|
252
|
+
Apply for a property
|
|
253
|
+
</h1>
|
|
232
254
|
<div className="mb-4">
|
|
233
255
|
<Link
|
|
234
256
|
to={propertyId ? `/property/${propertyId}` : "/properties"}
|
|
@@ -308,7 +330,7 @@ export default function Application() {
|
|
|
308
330
|
<Button
|
|
309
331
|
type="submit"
|
|
310
332
|
size="sm"
|
|
311
|
-
className="w-full cursor-pointer rounded-xl bg-primary px-5 py-5 text-lg font-medium transition-colors duration-200 hover:bg-primary/
|
|
333
|
+
className="w-full cursor-pointer rounded-xl bg-primary px-5 py-5 text-lg font-medium transition-colors duration-200 hover:bg-primary/95 disabled:opacity-50"
|
|
312
334
|
disabled={submitting}
|
|
313
335
|
>
|
|
314
336
|
{submitting ? "Submitting…" : "Submit application"}
|