@salesforce/ui-bundle-template-app-react-sample-b2x 11.55.1 → 11.56.1
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 +16 -0
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/package.json +4 -4
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/dashboard/WeatherWidget.tsx +73 -16
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/layout/TopBar.tsx +4 -3
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/layout/VerticalNav.tsx +5 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/maintenanceRequests/MaintenanceRequestIcon.tsx +9 -5
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/maintenanceRequests/MaintenanceSummaryDetailsModal.tsx +54 -11
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/properties/PropertyListingCard.tsx +15 -16
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/properties/PropertyMap.tsx +2 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/properties/PropertySearchFilters.tsx +20 -3
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Application.tsx +2 -2
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Contact.tsx +1 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Home.tsx +68 -44
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Maintenance.tsx +2 -1
- package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/PropertySearch.tsx +2 -1
- package/dist/package-lock.json +2 -2
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## [11.56.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.56.0...v11.56.1) (2026-08-12)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [11.56.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.55.1...v11.56.0) (2026-08-12)
|
|
15
|
+
|
|
16
|
+
**Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
6
22
|
## [11.55.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.55.0...v11.55.1) (2026-08-11)
|
|
7
23
|
|
|
8
24
|
**Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"graphql:schema": "node scripts/get-graphql-schema.mjs"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@salesforce/platform-sdk": "^11.
|
|
22
|
-
"@salesforce/ui-bundle": "^11.
|
|
21
|
+
"@salesforce/platform-sdk": "^11.56.1",
|
|
22
|
+
"@salesforce/ui-bundle": "^11.56.1",
|
|
23
23
|
"@tailwindcss/vite": "^4.1.17",
|
|
24
24
|
"class-variance-authority": "^0.7.1",
|
|
25
25
|
"clsx": "^2.1.1",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"@graphql-eslint/eslint-plugin": "^4.1.0",
|
|
51
51
|
"@graphql-tools/utils": "^11.0.0",
|
|
52
52
|
"@playwright/test": "^1.49.0",
|
|
53
|
-
"@salesforce/graphiti": "^11.
|
|
54
|
-
"@salesforce/vite-plugin-ui-bundle": "^11.
|
|
53
|
+
"@salesforce/graphiti": "^11.56.1",
|
|
54
|
+
"@salesforce/vite-plugin-ui-bundle": "^11.56.1",
|
|
55
55
|
"@testing-library/jest-dom": "^6.6.3",
|
|
56
56
|
"@testing-library/react": "^16.1.0",
|
|
57
57
|
"@testing-library/user-event": "^14.5.2",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
2
|
import { Card } from "@/components/ui/card";
|
|
3
3
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
4
4
|
import { useWeather, type WeatherData, type WeatherHour } from "@/hooks/useWeather";
|
|
@@ -24,6 +24,10 @@ const FORECAST_TABS: { key: ForecastTab; label: string }[] = [
|
|
|
24
24
|
{ key: "next3days", label: "Next 3 Days" },
|
|
25
25
|
];
|
|
26
26
|
|
|
27
|
+
/** Tab/panel ids wiring the forecast tablist to its single panel. */
|
|
28
|
+
const FORECAST_PANEL_ID = "weather-forecast-panel";
|
|
29
|
+
const tabId = (key: ForecastTab) => `weather-tab-${key}`;
|
|
30
|
+
|
|
27
31
|
const HOURLY_KEY: Record<
|
|
28
32
|
ForecastTab,
|
|
29
33
|
keyof Pick<WeatherData, "todayHourly" | "tomorrowHourly" | "next3DaysHourly">
|
|
@@ -120,7 +124,7 @@ function StatItem({
|
|
|
120
124
|
}) {
|
|
121
125
|
return (
|
|
122
126
|
<div className="flex flex-col items-center gap-1">
|
|
123
|
-
<Icon className="h-5 w-5 text-gray-400" />
|
|
127
|
+
<Icon className="h-5 w-5 text-gray-400" aria-hidden />
|
|
124
128
|
<p className="text-base font-semibold text-primary">{value}</p>
|
|
125
129
|
<p className="text-xs text-muted-foreground">{label}</p>
|
|
126
130
|
</div>
|
|
@@ -172,26 +176,79 @@ function ForecastTabs({
|
|
|
172
176
|
activeTab: ForecastTab;
|
|
173
177
|
onTabChange: (tab: ForecastTab) => void;
|
|
174
178
|
}) {
|
|
179
|
+
// Roving tabindex means only the active tab is in the page tab order, so the
|
|
180
|
+
// ARIA tablist pattern requires arrow/Home/End keys to move selection *and*
|
|
181
|
+
// focus between tabs — otherwise the other tabs become keyboard-unreachable.
|
|
182
|
+
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
183
|
+
|
|
184
|
+
const onKeyDown = (event: React.KeyboardEvent, index: number) => {
|
|
185
|
+
const last = FORECAST_TABS.length - 1;
|
|
186
|
+
let next: number;
|
|
187
|
+
switch (event.key) {
|
|
188
|
+
case "ArrowRight":
|
|
189
|
+
case "ArrowDown":
|
|
190
|
+
next = index === last ? 0 : index + 1;
|
|
191
|
+
break;
|
|
192
|
+
case "ArrowLeft":
|
|
193
|
+
case "ArrowUp":
|
|
194
|
+
next = index === 0 ? last : index - 1;
|
|
195
|
+
break;
|
|
196
|
+
case "Home":
|
|
197
|
+
next = 0;
|
|
198
|
+
break;
|
|
199
|
+
case "End":
|
|
200
|
+
next = last;
|
|
201
|
+
break;
|
|
202
|
+
default:
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
onTabChange(FORECAST_TABS[next].key);
|
|
207
|
+
tabRefs.current[next]?.focus();
|
|
208
|
+
};
|
|
209
|
+
|
|
175
210
|
return (
|
|
176
|
-
<div className="flex gap-6">
|
|
177
|
-
{FORECAST_TABS.map(({ key, label }) =>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
211
|
+
<div className="flex gap-6" role="tablist" aria-label="Forecast range">
|
|
212
|
+
{FORECAST_TABS.map(({ key, label }, index) => {
|
|
213
|
+
const selected = activeTab === key;
|
|
214
|
+
return (
|
|
215
|
+
<button
|
|
216
|
+
key={key}
|
|
217
|
+
type="button"
|
|
218
|
+
role="tab"
|
|
219
|
+
id={tabId(key)}
|
|
220
|
+
ref={(el) => {
|
|
221
|
+
tabRefs.current[index] = el;
|
|
222
|
+
}}
|
|
223
|
+
aria-selected={selected}
|
|
224
|
+
aria-controls={FORECAST_PANEL_ID}
|
|
225
|
+
tabIndex={selected ? 0 : -1}
|
|
226
|
+
onClick={() => onTabChange(key)}
|
|
227
|
+
onKeyDown={(event) => onKeyDown(event, index)}
|
|
228
|
+
className="flex flex-col items-center"
|
|
181
229
|
>
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
230
|
+
<span
|
|
231
|
+
className={`text-sm font-semibold ${selected ? "text-foreground" : "text-muted-foreground"}`}
|
|
232
|
+
>
|
|
233
|
+
{label}
|
|
234
|
+
</span>
|
|
235
|
+
{selected && <span className="mt-1 h-1.5 w-1.5 rounded-full bg-foreground" />}
|
|
236
|
+
</button>
|
|
237
|
+
);
|
|
238
|
+
})}
|
|
187
239
|
</div>
|
|
188
240
|
);
|
|
189
241
|
}
|
|
190
242
|
|
|
191
|
-
function HourlyForecast({ hours }: { hours: WeatherHour[] }) {
|
|
192
|
-
if (hours.length === 0) return null;
|
|
243
|
+
function HourlyForecast({ hours, activeTab }: { hours: WeatherHour[]; activeTab: ForecastTab }) {
|
|
193
244
|
return (
|
|
194
|
-
<div
|
|
245
|
+
<div
|
|
246
|
+
id={FORECAST_PANEL_ID}
|
|
247
|
+
role="tabpanel"
|
|
248
|
+
aria-labelledby={tabId(activeTab)}
|
|
249
|
+
tabIndex={0}
|
|
250
|
+
className="mt-4 flex gap-3 overflow-x-auto pb-1"
|
|
251
|
+
>
|
|
195
252
|
{hours.map((h) => (
|
|
196
253
|
<div
|
|
197
254
|
key={h.time}
|
|
@@ -227,7 +284,7 @@ export function WeatherWidget() {
|
|
|
227
284
|
<CurrentConditions weather={weather} />
|
|
228
285
|
<Divider />
|
|
229
286
|
<ForecastTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
|
230
|
-
<HourlyForecast hours={weather[HOURLY_KEY[activeTab]]} />
|
|
287
|
+
<HourlyForecast hours={weather[HOURLY_KEY[activeTab]]} activeTab={activeTab} />
|
|
231
288
|
</div>
|
|
232
289
|
)}
|
|
233
290
|
</Card>
|
package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/layout/TopBar.tsx
CHANGED
|
@@ -62,12 +62,13 @@ export function TopBar({ onMenuClick }: TopBarProps) {
|
|
|
62
62
|
|
|
63
63
|
function Logo() {
|
|
64
64
|
return (
|
|
65
|
-
<
|
|
66
|
-
|
|
65
|
+
<Link to="/" aria-label="ZENLEASE home" className="flex items-center gap-2">
|
|
66
|
+
{/* Decorative: the accessible name comes from the link's aria-label. */}
|
|
67
|
+
<img src={zenLogo} alt="" className="size-8" />
|
|
67
68
|
<span className="text-xl tracking-wide">
|
|
68
69
|
<span className="font-light">ZEN</span>
|
|
69
70
|
<span className="font-semibold">LEASE</span>
|
|
70
71
|
</span>
|
|
71
|
-
</
|
|
72
|
+
</Link>
|
|
72
73
|
);
|
|
73
74
|
}
|
|
@@ -84,9 +84,13 @@ export function VerticalNav({ isOpen = false, onClose }: VerticalNavProps) {
|
|
|
84
84
|
{isOpen && (
|
|
85
85
|
<>
|
|
86
86
|
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} aria-hidden />
|
|
87
|
+
{/*
|
|
88
|
+
* Distinct label from the desktop sidebar: both navs are in the DOM at the
|
|
89
|
+
* same time, and two navigation landmarks sharing one name is ambiguous.
|
|
90
|
+
*/}
|
|
87
91
|
<nav
|
|
88
92
|
className="fixed left-0 top-0 bottom-0 z-50 flex w-24 flex-col border-r border-gray-200 bg-white py-8"
|
|
89
|
-
aria-label="
|
|
93
|
+
aria-label="Mobile navigation"
|
|
90
94
|
>
|
|
91
95
|
{navContent(onClose)}
|
|
92
96
|
</nav>
|
|
@@ -29,17 +29,21 @@ export function MaintenanceRequestIcon({ type }: { type: string | null }) {
|
|
|
29
29
|
const iconSrc = issueIcons[issueType];
|
|
30
30
|
const bgClass = issueIconColors[issueType] ?? "bg-teal-100";
|
|
31
31
|
|
|
32
|
+
/*
|
|
33
|
+
* Purely decorative: every caller renders the request type as adjacent text, so
|
|
34
|
+
* exposing the icon would double-announce it. The wrapper's `aria-hidden` used to
|
|
35
|
+
* contradict a meaningful `alt` on the image — the image is now explicitly
|
|
36
|
+
* decorative so the intent is unambiguous.
|
|
37
|
+
*/
|
|
32
38
|
return (
|
|
33
39
|
<div
|
|
34
40
|
className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg ${bgClass}`}
|
|
35
|
-
aria-hidden
|
|
41
|
+
aria-hidden="true"
|
|
36
42
|
>
|
|
37
43
|
{iconSrc ? (
|
|
38
|
-
<img src={iconSrc} alt=
|
|
44
|
+
<img src={iconSrc} alt="" className="h-6 w-6" />
|
|
39
45
|
) : (
|
|
40
|
-
<span className="text-2xl"
|
|
41
|
-
🔧
|
|
42
|
-
</span>
|
|
46
|
+
<span className="text-2xl">🔧</span>
|
|
43
47
|
)}
|
|
44
48
|
</div>
|
|
45
49
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read-only maintenance details for B2C Maintenance page (summary rows from maintenanceRequestApi).
|
|
3
3
|
*/
|
|
4
|
-
import { useEffect } from "react";
|
|
4
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
5
5
|
import { X } from "lucide-react";
|
|
6
6
|
import { StatusBadge } from "@/components/maintenanceRequests/StatusBadge";
|
|
7
7
|
import type { MaintenanceRequestNode } from "@/api/maintenanceRequests/maintenanceRequestApi";
|
|
@@ -11,6 +11,11 @@ export interface MaintenanceSummaryDetailsModalProps {
|
|
|
11
11
|
onClose: () => void;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
const TITLE_ID = "maintenance-summary-details-title";
|
|
15
|
+
|
|
16
|
+
const FOCUSABLE_SELECTOR =
|
|
17
|
+
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
|
|
18
|
+
|
|
14
19
|
function formatDate(value: string | null): string {
|
|
15
20
|
if (!value?.trim()) return "—";
|
|
16
21
|
try {
|
|
@@ -32,28 +37,66 @@ export default function MaintenanceSummaryDetailsModal({
|
|
|
32
37
|
request,
|
|
33
38
|
onClose,
|
|
34
39
|
}: MaintenanceSummaryDetailsModalProps) {
|
|
40
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
42
|
+
|
|
43
|
+
// Move focus into the dialog on open and return it to the invoking element on close.
|
|
35
44
|
useEffect(() => {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
46
|
+
closeButtonRef.current?.focus();
|
|
47
|
+
return () => previouslyFocused?.focus?.();
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const handleKeyDown = useCallback(
|
|
51
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
52
|
+
if (e.key === "Escape") {
|
|
53
|
+
onClose();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (e.key !== "Tab") return;
|
|
57
|
+
|
|
58
|
+
// Trap Tab within the dialog (WCAG 2.4.3 / 2.1.2 for modal content).
|
|
59
|
+
const focusable = Array.from(
|
|
60
|
+
dialogRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [],
|
|
61
|
+
);
|
|
62
|
+
if (focusable.length === 0) return;
|
|
63
|
+
const first = focusable[0];
|
|
64
|
+
const last = focusable[focusable.length - 1];
|
|
65
|
+
const active = document.activeElement;
|
|
66
|
+
if (e.shiftKey && (active === first || !dialogRef.current?.contains(active))) {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
last.focus();
|
|
69
|
+
} else if (!e.shiftKey && active === last) {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
first.focus();
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
[onClose],
|
|
75
|
+
);
|
|
42
76
|
|
|
43
77
|
return (
|
|
44
78
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
45
79
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} aria-hidden />
|
|
46
80
|
<div
|
|
81
|
+
ref={dialogRef}
|
|
47
82
|
role="dialog"
|
|
48
|
-
aria-modal
|
|
83
|
+
aria-modal="true"
|
|
84
|
+
aria-labelledby={TITLE_ID}
|
|
85
|
+
onKeyDown={handleKeyDown}
|
|
49
86
|
className="relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg bg-white shadow-xl"
|
|
50
87
|
>
|
|
51
88
|
<div className="flex items-center justify-between border-b p-4">
|
|
52
|
-
<h2 className="text-lg font-semibold">
|
|
89
|
+
<h2 id={TITLE_ID} className="text-lg font-semibold">
|
|
53
90
|
{request.Description__c?.value ?? request.Name?.value ?? "Request"}
|
|
54
91
|
</h2>
|
|
55
|
-
<button
|
|
56
|
-
|
|
92
|
+
<button
|
|
93
|
+
ref={closeButtonRef}
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={onClose}
|
|
96
|
+
aria-label="Close dialog"
|
|
97
|
+
className="text-gray-500 hover:text-gray-800"
|
|
98
|
+
>
|
|
99
|
+
<X className="h-5 w-5" aria-hidden />
|
|
57
100
|
</button>
|
|
58
101
|
</div>
|
|
59
102
|
<div className="space-y-3 p-4 text-sm">
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Stacked property listing card: image on top, details below. Single price + bedrooms, amenity list, Apply button.
|
|
3
3
|
* No phone or secondary price. Virtual Tours / Videos pills on image.
|
|
4
4
|
*/
|
|
5
|
-
import { useNavigate } from "react-router";
|
|
5
|
+
import { useNavigate, Link } from "react-router";
|
|
6
6
|
import { useCallback, type MouseEvent } from "react";
|
|
7
7
|
import { Button } from "@/components/ui/button";
|
|
8
8
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
@@ -74,16 +74,6 @@ export default function PropertyListingCard({
|
|
|
74
74
|
navigate(detailPath);
|
|
75
75
|
}, [navigate, detailPath]);
|
|
76
76
|
|
|
77
|
-
const handleKeyDown = useCallback(
|
|
78
|
-
(e: React.KeyboardEvent) => {
|
|
79
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
80
|
-
e.preventDefault();
|
|
81
|
-
handleClick();
|
|
82
|
-
}
|
|
83
|
-
},
|
|
84
|
-
[handleClick],
|
|
85
|
-
);
|
|
86
|
-
|
|
87
77
|
if (loading) {
|
|
88
78
|
return <PropertyListingCardSkeleton />;
|
|
89
79
|
}
|
|
@@ -94,14 +84,15 @@ export default function PropertyListingCard({
|
|
|
94
84
|
.map((s) => s.trim())
|
|
95
85
|
.filter(Boolean);
|
|
96
86
|
|
|
87
|
+
/*
|
|
88
|
+
* The card is a pointer-only shortcut to the detail page; the keyboard/AT path is the
|
|
89
|
+
* heading link below. Making the whole card `role="button"` would nest the Apply
|
|
90
|
+
* button inside an interactive control (axe `nested-interactive`) and swallow its name.
|
|
91
|
+
*/
|
|
97
92
|
return (
|
|
98
93
|
<article
|
|
99
94
|
className="flex h-full min-h-0 cursor-pointer flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-all duration-200 hover:shadow-md focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2"
|
|
100
95
|
onClick={handleClick}
|
|
101
|
-
onKeyDown={handleKeyDown}
|
|
102
|
-
role="button"
|
|
103
|
-
tabIndex={0}
|
|
104
|
-
aria-label={`View details for ${name}`}
|
|
105
96
|
>
|
|
106
97
|
{/* Image on top, full width */}
|
|
107
98
|
<div className="relative aspect-[16/10] w-full shrink-0 overflow-hidden rounded-t-2xl bg-muted">
|
|
@@ -126,7 +117,15 @@ export default function PropertyListingCard({
|
|
|
126
117
|
<div className="flex min-h-0 flex-1 flex-col justify-between p-3">
|
|
127
118
|
<div className="min-h-0">
|
|
128
119
|
<div className="mb-1.5">
|
|
129
|
-
<h3 className="text-2xl font-semibold text-foreground">
|
|
120
|
+
<h3 className="text-2xl font-semibold text-foreground">
|
|
121
|
+
<Link
|
|
122
|
+
to={detailPath}
|
|
123
|
+
onClick={(e: MouseEvent<HTMLAnchorElement>) => e.stopPropagation()}
|
|
124
|
+
className="no-underline hover:underline focus-visible:outline-none"
|
|
125
|
+
>
|
|
126
|
+
{name}
|
|
127
|
+
</Link>
|
|
128
|
+
</h3>
|
|
130
129
|
{displayAddress && (
|
|
131
130
|
<p className="truncate text-sm text-muted-foreground">{displayAddress}</p>
|
|
132
131
|
)}
|
|
@@ -172,8 +172,9 @@ export default function PropertyMap({
|
|
|
172
172
|
return center;
|
|
173
173
|
}, [center, hasMarkers, markers]);
|
|
174
174
|
|
|
175
|
+
// `aria-label` needs a role to be exposed; a labelled region is what a map container is.
|
|
175
176
|
return (
|
|
176
|
-
<div className={`relative ${className}`} aria-label="Property map">
|
|
177
|
+
<div className={`relative ${className}`} role="region" aria-label="Property map">
|
|
177
178
|
<MapContent
|
|
178
179
|
center={effectiveCenter}
|
|
179
180
|
zoom={zoom}
|
|
@@ -16,6 +16,14 @@ const SAVE_BUTTON_CLASS =
|
|
|
16
16
|
const DEFAULT_PRICE_MIN = 0;
|
|
17
17
|
const DEFAULT_PRICE_MAX = 100_000;
|
|
18
18
|
|
|
19
|
+
/*
|
|
20
|
+
* Radix renders each `Popover.Content` as `role="dialog"`, which needs an
|
|
21
|
+
* accessible name. Each popover's own heading supplies it via `aria-labelledby`.
|
|
22
|
+
*/
|
|
23
|
+
const PRICE_POPOVER_TITLE_ID = "property-filter-price-title";
|
|
24
|
+
const BEDS_POPOVER_TITLE_ID = "property-filter-beds-title";
|
|
25
|
+
const SORT_POPOVER_TITLE_ID = "property-filter-sort-title";
|
|
26
|
+
|
|
19
27
|
/** Bedroom filter buckets: ≤2, exactly 3, or ≥4 */
|
|
20
28
|
export type BedroomFilter = "le2" | "3" | "ge4" | null;
|
|
21
29
|
|
|
@@ -145,10 +153,13 @@ export default function PropertySearchFilters({
|
|
|
145
153
|
<Popover.Content
|
|
146
154
|
sideOffset={8}
|
|
147
155
|
align="start"
|
|
156
|
+
aria-labelledby={PRICE_POPOVER_TITLE_ID}
|
|
148
157
|
className="z-[1100] w-[min(20rem,90vw)] rounded-xl border border-border bg-background p-4 shadow-lg outline-none"
|
|
149
158
|
>
|
|
150
159
|
<div className="space-y-3">
|
|
151
|
-
<p className="text-sm font-medium text-foreground">
|
|
160
|
+
<p id={PRICE_POPOVER_TITLE_ID} className="text-sm font-medium text-foreground">
|
|
161
|
+
Price range
|
|
162
|
+
</p>
|
|
152
163
|
<div className="flex items-center gap-2">
|
|
153
164
|
<div className="flex flex-1 items-center rounded-full border-2 border-primary/50 bg-background focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20">
|
|
154
165
|
<span className="pl-4 text-sm text-muted-foreground">$</span>
|
|
@@ -211,10 +222,13 @@ export default function PropertySearchFilters({
|
|
|
211
222
|
<Popover.Content
|
|
212
223
|
sideOffset={8}
|
|
213
224
|
align="start"
|
|
225
|
+
aria-labelledby={BEDS_POPOVER_TITLE_ID}
|
|
214
226
|
className="z-[1100] w-[min(20rem,90vw)] rounded-xl border border-border bg-background p-4 shadow-lg outline-none"
|
|
215
227
|
>
|
|
216
228
|
<div className="space-y-3">
|
|
217
|
-
<p className="text-sm font-medium text-foreground">
|
|
229
|
+
<p id={BEDS_POPOVER_TITLE_ID} className="text-sm font-medium text-foreground">
|
|
230
|
+
Number of bedrooms
|
|
231
|
+
</p>
|
|
218
232
|
<div
|
|
219
233
|
className="flex overflow-hidden rounded-full border-2 border-primary/40"
|
|
220
234
|
role="group"
|
|
@@ -274,10 +288,13 @@ export default function PropertySearchFilters({
|
|
|
274
288
|
<Popover.Content
|
|
275
289
|
sideOffset={8}
|
|
276
290
|
align="start"
|
|
291
|
+
aria-labelledby={SORT_POPOVER_TITLE_ID}
|
|
277
292
|
className="z-[1100] w-[min(20rem,90vw)] rounded-xl border border-border bg-background p-4 shadow-lg outline-none"
|
|
278
293
|
>
|
|
279
294
|
<div className="space-y-3">
|
|
280
|
-
<p className="text-sm font-medium text-foreground">
|
|
295
|
+
<p id={SORT_POPOVER_TITLE_ID} className="text-sm font-medium text-foreground">
|
|
296
|
+
Sort by
|
|
297
|
+
</p>
|
|
281
298
|
<div className="flex flex-col gap-1">
|
|
282
299
|
{SORT_OPTIONS.map(({ value, label }) => (
|
|
283
300
|
<button
|
|
@@ -237,7 +237,7 @@ export default function Application() {
|
|
|
237
237
|
))}
|
|
238
238
|
</p>
|
|
239
239
|
{loadError && (
|
|
240
|
-
<p className="mt-2 text-sm text-destructive">
|
|
240
|
+
<p className="mt-2 text-sm text-destructive" role="alert">
|
|
241
241
|
Something went wrong. Please try again later.
|
|
242
242
|
</p>
|
|
243
243
|
)}
|
|
@@ -279,7 +279,7 @@ export default function Application() {
|
|
|
279
279
|
/>
|
|
280
280
|
</div>
|
|
281
281
|
{submitError && (
|
|
282
|
-
<p className="mb-4 text-sm text-destructive">
|
|
282
|
+
<p className="mb-4 text-sm text-destructive" role="alert">
|
|
283
283
|
Something went wrong. Please try again later.
|
|
284
284
|
</p>
|
|
285
285
|
)}
|
|
@@ -30,6 +30,10 @@ import {
|
|
|
30
30
|
Twitter,
|
|
31
31
|
} from "lucide-react";
|
|
32
32
|
|
|
33
|
+
/** FAQ accordion ids — shared between the toggle button and the panel it controls. */
|
|
34
|
+
const faqButtonId = (index: number) => `faq-toggle-${index}`;
|
|
35
|
+
const faqPanelId = (index: number) => `faq-panel-${index}`;
|
|
36
|
+
|
|
33
37
|
const HERO_IMAGE = "https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=1200&q=85";
|
|
34
38
|
const CITY_IMAGE = "https://images.unsplash.com/photo-1514565131-fce0801e5785?w=800&q=85";
|
|
35
39
|
const RELAX_IMAGE = "https://images.unsplash.com/photo-1586023492125-27b2c045efd7?w=600&q=85";
|
|
@@ -374,30 +378,45 @@ export default function Home() {
|
|
|
374
378
|
Frequently Asked Questions
|
|
375
379
|
</h2>
|
|
376
380
|
<div className="space-y-2">
|
|
377
|
-
{FAQ_ITEMS.map((item, index) =>
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
type="button"
|
|
384
|
-
onClick={() => setFaqOpen(faqOpen === index ? null : index)}
|
|
385
|
-
className="flex w-full cursor-pointer items-center justify-between gap-4 px-5 py-4 text-left font-medium text-foreground transition-colors duration-200 hover:bg-muted/50"
|
|
381
|
+
{FAQ_ITEMS.map((item, index) => {
|
|
382
|
+
const expanded = faqOpen === index;
|
|
383
|
+
return (
|
|
384
|
+
<div
|
|
385
|
+
key={index}
|
|
386
|
+
className="overflow-hidden rounded-xl border border-teal-100 bg-white shadow-sm"
|
|
386
387
|
>
|
|
387
|
-
<
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
388
|
+
<h3>
|
|
389
|
+
<button
|
|
390
|
+
type="button"
|
|
391
|
+
id={faqButtonId(index)}
|
|
392
|
+
aria-expanded={expanded}
|
|
393
|
+
aria-controls={faqPanelId(index)}
|
|
394
|
+
onClick={() => setFaqOpen(expanded ? null : index)}
|
|
395
|
+
className="flex w-full cursor-pointer items-center justify-between gap-4 px-5 py-4 text-left font-medium text-foreground transition-colors duration-200 hover:bg-muted/50"
|
|
396
|
+
>
|
|
397
|
+
<span>{item.q}</span>
|
|
398
|
+
{expanded ? (
|
|
399
|
+
<ChevronUp className="size-5 shrink-0 text-muted-foreground" aria-hidden />
|
|
400
|
+
) : (
|
|
401
|
+
<ChevronDown
|
|
402
|
+
className="size-5 shrink-0 text-muted-foreground"
|
|
403
|
+
aria-hidden
|
|
404
|
+
/>
|
|
405
|
+
)}
|
|
406
|
+
</button>
|
|
407
|
+
</h3>
|
|
408
|
+
<div
|
|
409
|
+
id={faqPanelId(index)}
|
|
410
|
+
role="region"
|
|
411
|
+
aria-labelledby={faqButtonId(index)}
|
|
412
|
+
hidden={!expanded}
|
|
413
|
+
className="border-t border-teal-100 px-5 py-4 text-sm text-muted-foreground"
|
|
414
|
+
>
|
|
396
415
|
{item.a}
|
|
397
416
|
</div>
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
)
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
})}
|
|
401
420
|
</div>
|
|
402
421
|
</div>
|
|
403
422
|
</section>
|
|
@@ -422,27 +441,33 @@ export default function Home() {
|
|
|
422
441
|
<Button
|
|
423
442
|
type="submit"
|
|
424
443
|
size="icon"
|
|
444
|
+
aria-label="Subscribe to newsletter"
|
|
425
445
|
className="shrink-0 bg-teal-600 hover:bg-teal-700"
|
|
426
446
|
disabled={newsletterSubmitting}
|
|
427
447
|
>
|
|
428
448
|
<Send className="size-4" aria-hidden />
|
|
429
449
|
</Button>
|
|
430
450
|
</div>
|
|
431
|
-
{
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
451
|
+
{/* Live region stays mounted so the result is announced when it arrives. */}
|
|
452
|
+
<p
|
|
453
|
+
role="status"
|
|
454
|
+
aria-live="polite"
|
|
455
|
+
className={
|
|
456
|
+
newsletterMessage == null
|
|
457
|
+
? "sr-only"
|
|
458
|
+
: newsletterMessage.type === "success"
|
|
459
|
+
? "text-teal-200"
|
|
460
|
+
: "text-red-300"
|
|
461
|
+
}
|
|
462
|
+
>
|
|
463
|
+
{newsletterMessage?.text ?? ""}
|
|
464
|
+
</p>
|
|
440
465
|
</form>
|
|
441
466
|
</div>
|
|
442
467
|
<div>
|
|
443
|
-
<
|
|
468
|
+
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-white">
|
|
444
469
|
Explore
|
|
445
|
-
</
|
|
470
|
+
</h3>
|
|
446
471
|
<ul className="space-y-2 text-sm">
|
|
447
472
|
<li>
|
|
448
473
|
<Link to="/" className="text-teal-200 hover:text-white">
|
|
@@ -462,9 +487,9 @@ export default function Home() {
|
|
|
462
487
|
</ul>
|
|
463
488
|
</div>
|
|
464
489
|
<div>
|
|
465
|
-
<
|
|
490
|
+
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-white">
|
|
466
491
|
Support
|
|
467
|
-
</
|
|
492
|
+
</h3>
|
|
468
493
|
<ul className="space-y-2 text-sm">
|
|
469
494
|
<li>
|
|
470
495
|
<Link to="/contact" className="text-teal-200 hover:text-white">
|
|
@@ -488,16 +513,15 @@ export default function Home() {
|
|
|
488
513
|
<p className="text-xs text-teal-300">
|
|
489
514
|
© {new Date().getFullYear()} ZENLEASE. All rights reserved. Terms & Conditions
|
|
490
515
|
</p>
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
<
|
|
499
|
-
|
|
500
|
-
</a>
|
|
516
|
+
{/*
|
|
517
|
+
* Brand marks only. Rendered as decorative, non-focusable icons rather than
|
|
518
|
+
* `href="#"` links: a link that goes nowhere is a keyboard trap for no benefit
|
|
519
|
+
* (WCAG 2.4.4 / 3.2.4). Swap in real anchors when the social URLs exist.
|
|
520
|
+
*/}
|
|
521
|
+
<div className="flex gap-4 text-teal-300" aria-hidden="true">
|
|
522
|
+
<Facebook className="size-5" />
|
|
523
|
+
<Instagram className="size-5" />
|
|
524
|
+
<Twitter className="size-5" />
|
|
501
525
|
</div>
|
|
502
526
|
</div>
|
|
503
527
|
</footer>
|
|
@@ -214,7 +214,8 @@ export default function Maintenance() {
|
|
|
214
214
|
)}
|
|
215
215
|
{tenantProperties.length === 1 && selectedTenant && (
|
|
216
216
|
<div className="space-y-2">
|
|
217
|
-
<
|
|
217
|
+
{/* Read-only value, not a form control — a <label> here would have nothing to label. */}
|
|
218
|
+
<p className="text-sm font-medium leading-none">Property</p>
|
|
218
219
|
<p className="text-sm text-muted-foreground">
|
|
219
220
|
{selectedTenant.Property__r?.Address__c?.value ??
|
|
220
221
|
selectedTenant.Property__r?.Name?.value}
|
package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/PropertySearch.tsx
CHANGED
|
@@ -360,7 +360,8 @@ export default function PropertySearch() {
|
|
|
360
360
|
{/* Main: map 2/3, list 1/3 */}
|
|
361
361
|
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
|
362
362
|
{/* Map – 2/3 on desktop */}
|
|
363
|
-
|
|
363
|
+
{/* Sizing wrapper only — PropertyMap renders the labelled map region. */}
|
|
364
|
+
<div className="isolate h-64 shrink-0 lg:h-full lg:min-h-0 lg:w-2/3">
|
|
364
365
|
<PropertyMap
|
|
365
366
|
center={MAP_CENTER_FALLBACK}
|
|
366
367
|
zoom={mapMarkers.length > 0 ? MAP_ZOOM_WITH_MARKERS : MAP_ZOOM_DEFAULT}
|
package/dist/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/ui-bundle-template-base-sfdx-project",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.56.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@salesforce/ui-bundle-template-base-sfdx-project",
|
|
9
|
-
"version": "11.
|
|
9
|
+
"version": "11.56.1",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"fast-xml-parser": "^5.9.3",
|
package/dist/package.json
CHANGED