@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.
Files changed (18) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/uiBundles/propertyrentalapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/dashboard/WeatherWidget.tsx +73 -16
  4. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/layout/TopBar.tsx +4 -3
  5. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/layout/VerticalNav.tsx +5 -1
  6. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/maintenanceRequests/MaintenanceRequestIcon.tsx +9 -5
  7. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/maintenanceRequests/MaintenanceSummaryDetailsModal.tsx +54 -11
  8. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/properties/PropertyListingCard.tsx +15 -16
  9. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/properties/PropertyMap.tsx +2 -1
  10. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/properties/PropertySearchFilters.tsx +20 -3
  11. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Application.tsx +2 -2
  12. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Contact.tsx +1 -1
  13. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Home.tsx +68 -44
  14. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Maintenance.tsx +2 -1
  15. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/PropertySearch.tsx +2 -1
  16. package/dist/package-lock.json +2 -2
  17. package/dist/package.json +1 -1
  18. 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.55.1",
22
- "@salesforce/ui-bundle": "^11.55.1",
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.55.1",
54
- "@salesforce/vite-plugin-ui-bundle": "^11.55.1",
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
- <button key={key} onClick={() => onTabChange(key)} className="flex flex-col items-center">
179
- <span
180
- className={`text-sm font-semibold ${activeTab === key ? "text-foreground" : "text-muted-foreground"}`}
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
- {label}
183
- </span>
184
- {activeTab === key && <span className="mt-1 h-1.5 w-1.5 rounded-full bg-foreground" />}
185
- </button>
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 className="mt-4 flex gap-3 overflow-x-auto pb-1">
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>
@@ -62,12 +62,13 @@ export function TopBar({ onMenuClick }: TopBarProps) {
62
62
 
63
63
  function Logo() {
64
64
  return (
65
- <div className="flex items-center gap-2">
66
- <img src={zenLogo} alt="Zenlease Logo" className="size-8" />
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
- </div>
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="Main navigation"
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={issueType || "Request"} className="h-6 w-6" />
44
+ <img src={iconSrc} alt="" className="h-6 w-6" />
39
45
  ) : (
40
- <span className="text-2xl" aria-hidden>
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 handleEscape = (e: KeyboardEvent) => {
37
- if (e.key === "Escape") onClose();
38
- };
39
- document.addEventListener("keydown", handleEscape);
40
- return () => document.removeEventListener("keydown", handleEscape);
41
- }, [onClose]);
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 type="button" onClick={onClose} className="text-gray-500 hover:text-gray-800">
56
- <X className="h-5 w-5" />
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">{name}</h3>
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">Price range</p>
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">Number of bedrooms</p>
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">Sort by</p>
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
  )}
@@ -148,7 +148,7 @@ function ContactForm({
148
148
  />
149
149
  </div>
150
150
  {submitError && (
151
- <p className="text-sm text-destructive">
151
+ <p className="text-sm text-destructive" role="alert">
152
152
  Something went wrong. Please try again later.
153
153
  </p>
154
154
  )}
@@ -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
- <div
379
- key={index}
380
- className="overflow-hidden rounded-xl border border-teal-100 bg-white shadow-sm"
381
- >
382
- <button
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
- <span>{item.q}</span>
388
- {faqOpen === index ? (
389
- <ChevronUp className="size-5 shrink-0 text-muted-foreground" />
390
- ) : (
391
- <ChevronDown className="size-5 shrink-0 text-muted-foreground" />
392
- )}
393
- </button>
394
- {faqOpen === index && (
395
- <div className="border-t border-teal-100 px-5 py-4 text-sm text-muted-foreground">
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
- </div>
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
- {newsletterMessage && (
432
- <p
433
- className={
434
- newsletterMessage.type === "success" ? "text-teal-200" : "text-red-300"
435
- }
436
- >
437
- {newsletterMessage.text}
438
- </p>
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
- <h4 className="mb-3 text-sm font-semibold uppercase tracking-wider text-white">
468
+ <h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-white">
444
469
  Explore
445
- </h4>
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
- <h4 className="mb-3 text-sm font-semibold uppercase tracking-wider text-white">
490
+ <h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-white">
466
491
  Support
467
- </h4>
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
- <div className="flex gap-4">
492
- <a href="#" className="text-teal-300 hover:text-white" aria-label="Facebook">
493
- <Facebook className="size-5" aria-hidden />
494
- </a>
495
- <a href="#" className="text-teal-300 hover:text-white" aria-label="Instagram">
496
- <Instagram className="size-5" aria-hidden />
497
- </a>
498
- <a href="#" className="text-teal-300 hover:text-white" aria-label="Twitter">
499
- <Twitter className="size-5" aria-hidden />
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
- <Label>Property</Label>
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}
@@ -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
- <div className="isolate h-64 shrink-0 lg:h-full lg:min-h-0 lg:w-2/3" aria-label="Map">
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}
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
3
- "version": "11.55.1",
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.55.1",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
3
- "version": "11.55.1",
3
+ "version": "11.56.1",
4
4
  "description": "Base SFDX project template",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "publishConfig": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-app-react-sample-b2x",
3
- "version": "11.55.1",
3
+ "version": "11.56.1",
4
4
  "description": "Salesforce sample property rental React app",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "author": "",