diff --git a/apps/web/components/listings/listing-form-steps.tsx b/apps/web/components/listings/listing-form-steps.tsx index c9eaba4..6b49325 100644 --- a/apps/web/components/listings/listing-form-steps.tsx +++ b/apps/web/components/listings/listing-form-steps.tsx @@ -1,6 +1,7 @@ 'use client'; import dynamic from 'next/dynamic'; +import { useEffect, useRef, useState } from 'react'; import type { UseFormRegister, UseFormSetValue, UseFormWatch, FieldErrors } from 'react-hook-form'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -43,9 +44,13 @@ interface StepLocationProps extends StepProps { watch?: UseFormWatch; } -function FieldError({ message }: { message?: string }) { +function FieldError({ id, message }: { id: string; message?: string }) { if (!message) return null; - return

{message}

; + return ( + + ); } // ─── Step 1: Basic Info ────────────────────────────────── @@ -58,7 +63,12 @@ export function StepBasicInfo({ register, errors }: StepProps) {
- {TRANSACTION_TYPES.map((t) => ( ))} - +
- {PROPERTY_TYPES.map((t) => ( ))} - +
- - + +
@@ -95,9 +116,11 @@ export function StepBasicInfo({ register, errors }: StepProps) { id="description" rows={5} placeholder="Mô tả chi tiết về bất động sản..." + aria-invalid={!!errors.description} + aria-describedby={errors.description ? 'description-error' : undefined} {...register('description')} /> - +
); @@ -115,10 +138,35 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation const latValid = latNum != null && Number.isFinite(latNum) && latNum >= -90 && latNum <= 90; const lngValid = lngNum != null && Number.isFinite(lngNum) && lngNum >= -180 && lngNum <= 180; + // Live region message announced when the map geocoder resolves a location. + const [locationAnnouncement, setLocationAnnouncement] = useState(''); + const announcementTimerRef = useRef | null>(null); + + // Clear announcement after it has been read to avoid stale text being + // re-announced on re-render. + useEffect(() => { + if (locationAnnouncement) { + announcementTimerRef.current = setTimeout(() => setLocationAnnouncement(''), 3000); + } + return () => { + if (announcementTimerRef.current) clearTimeout(announcementTimerRef.current); + }; + }, [locationAnnouncement]); + return (

Vị trí

+ {/* Visually-hidden live region for map-picker location announcements */} +
+ {locationAnnouncement} +
+ {setValue && ( 0) { + setLocationAnnouncement(`Đã cập nhật vị trí: ${parts.join(', ')}`); + } } }} height="360px" @@ -139,25 +192,49 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation
- - + +
- - + +
- - + +
- - + +
@@ -169,9 +246,11 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation type="number" step="any" placeholder="VD: 10.7769" + aria-invalid={!!errors.latitude} + aria-describedby={errors.latitude ? 'latitude-error' : undefined} {...register('latitude')} /> - +
@@ -180,9 +259,11 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation type="number" step="any" placeholder="VD: 106.7009" + aria-invalid={!!errors.longitude} + aria-describedby={errors.longitude ? 'longitude-error' : undefined} {...register('longitude')} /> - +
@@ -199,8 +280,16 @@ export function StepDetails({ register, errors }: StepProps) {
- - + +
@@ -360,8 +449,14 @@ export function StepPricing({ register, errors }: StepProps) {
- - + +

Nhập số không có dấu chấm hoặc dấu phẩy

diff --git a/apps/web/components/ui/__tests__/dialog.spec.tsx b/apps/web/components/ui/__tests__/dialog.spec.tsx index 6e0b7af..c9fe655 100644 --- a/apps/web/components/ui/__tests__/dialog.spec.tsx +++ b/apps/web/components/ui/__tests__/dialog.spec.tsx @@ -68,4 +68,53 @@ describe('Dialog', () => { await userEvent.click(screen.getByText('Stay Open')); expect(onOpenChange).not.toHaveBeenCalled(); }); + + describe('a11y: DialogContext auto-labelling', () => { + it('renders DialogContent with role="dialog" and aria-modal', () => { + render( + {}}> + + A11y Title + + , + ); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + }); + + it('auto-wires aria-labelledby from DialogTitle id', () => { + render( + {}}> + + Auto Label + Auto Desc + + , + ); + + const dialog = screen.getByRole('dialog'); + const titleId = dialog.getAttribute('aria-labelledby'); + const descId = dialog.getAttribute('aria-describedby'); + + expect(titleId).toBeTruthy(); + expect(descId).toBeTruthy(); + + // The title element should carry the matching id + expect(screen.getByText('Auto Label')).toHaveAttribute('id', titleId); + expect(screen.getByText('Auto Desc')).toHaveAttribute('id', descId); + }); + + it('allows explicit id override on DialogTitle', () => { + render( + {}}> + + Custom + + , + ); + + expect(screen.getByText('Custom')).toHaveAttribute('id', 'custom-title'); + }); + }); }); diff --git a/apps/web/components/ui/dialog.tsx b/apps/web/components/ui/dialog.tsx index 2217f5f..fb27ebe 100644 --- a/apps/web/components/ui/dialog.tsx +++ b/apps/web/components/ui/dialog.tsx @@ -3,6 +3,23 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; +/* ------------------------------------------------------------------ */ +/* DialogContext — auto-wires aria-labelledby / aria-describedby */ +/* ------------------------------------------------------------------ */ + +interface DialogContextValue { + titleId: string; + descriptionId: string; +} + +const DialogContext = React.createContext(null); + +function useDialogContext() { + return React.useContext(DialogContext); +} + +/* ------------------------------------------------------------------ */ + interface DialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -10,6 +27,10 @@ interface DialogProps { } function Dialog({ open, onOpenChange, children }: DialogProps) { + const reactId = React.useId(); + const titleId = `${reactId}-dialog-title`; + const descriptionId = `${reactId}-dialog-desc`; + React.useEffect(() => { if (open) { document.body.style.overflow = 'hidden'; @@ -24,34 +45,43 @@ function Dialog({ open, onOpenChange, children }: DialogProps) { if (!open) return null; return ( -
-
onOpenChange(false)} - /> -
- {children} + +
+
onOpenChange(false)} + /> +
+ {children} +
-
+
); } const DialogContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes ->(({ className, children, ...props }, ref) => ( -
e.stopPropagation()} - {...props} - > - {children} -
-)); +>(({ className, children, ...props }, ref) => { + const ctx = useDialogContext(); + return ( +
e.stopPropagation()} + {...props} + > + {children} +
+ ); +}); DialogContent.displayName = 'DialogContent'; function DialogHeader({ className, ...props }: React.HTMLAttributes) { @@ -60,15 +90,25 @@ function DialogHeader({ className, ...props }: React.HTMLAttributes) { +function DialogTitle({ className, id, ...props }: React.HTMLAttributes) { + const ctx = useDialogContext(); return ( -

+

); } -function DialogDescription({ className, ...props }: React.HTMLAttributes) { +function DialogDescription({ className, id, ...props }: React.HTMLAttributes) { + const ctx = useDialogContext(); return ( -

+

); }