Hospitality Booking Widget

A custom date-range + guest-count search widget with interdependent state, real-time pricing, and full keyboard accessibility.

The demo instruments memoization boundaries via React.memo and useMemo, and implements focus-trapping in a fully accessible modal.

Key code powering the booking widget. Highlights show the custom hook, memoization strategy, and accessible modal implementation.

useBookingFilter — Custom Hook

Co-locates date and guest state with useCallback setters and a useMemo-driven price estimate. Invalid date ranges are silently rejected.

export function useBookingFilter() {
  const [checkIn, setCheckIn] = useState<Date | null>(null);
  const [checkOut, setCheckOut] = useState<Date | null>(null);
  const [guests, setGuests] = useState<GuestCounts>({ adults: 1, children: 0, infants: 0 });

  const updateDates = useCallback((newCheckIn: Date | null, newCheckOut: Date | null) => {
    if (newCheckIn && newCheckOut && newCheckOut <= newCheckIn) {
      return; // Reject invalid range
    }
    setCheckIn(newCheckIn);
    setCheckOut(newCheckOut);
  }, []);

  const updateGuests = useCallback((type: keyof GuestCounts, count: number) => {
    setGuests(prev => {
      const limit = GUEST_LIMITS[type];
      if (count < limit.min || count > limit.max) return prev;
      return { ...prev, [type]: count };
    });
  }, []);

  const priceEstimate = useMemo(() => {
    if (!checkIn || !checkOut) return 0;
    const timeDiff = checkOut.getTime() - checkIn.getTime();
    if (timeDiff <= 0) return 0;

    const nights = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
    const guestMultiplier = guests.adults + (guests.children * CHILD_MULTIPLIER);
    return nights * BASE_PRICE * guestMultiplier;
  }, [checkIn, checkOut, guests.adults, guests.children]);

  return { checkIn, checkOut, guests, priceEstimate, updateDates, updateGuests };
}

Memoized Counter (React.memo)

Each guest-type counter is wrapped in React.memo so rapid clicks on one counter never re-render the others or the date picker.

const Counter = memo(({ label, value, min, max, onChange }: CounterProps) => {
  return (
    <div className="flex items-center justify-between py-3">
      <span>{label}</span>
      <div className="flex items-center space-x-4">
        <button
          onClick={() => onChange(value - 1)}
          disabled={value <= min}
          aria-label={`Decrease ${label}`}
        >−</button>
        <span aria-live="polite">{value}</span>
        <button
          onClick={() => onChange(value + 1)}
          disabled={value >= max}
          aria-label={`Increase ${label}`}
        >+</button>
      </div>
    </div>
  );
});
Counter.displayName = 'Counter';

Accessible Modal — Focus Trap

The modal traps Tab focus between the first and last focusable elements and closes on Escape. On open, focus moves into the dialog; on close it restores to the trigger button.

// Focus trap + keyboard navigation
useEffect(() => {
  if (!isOpen) return;
  const modalNode = modalRef.current;
  if (!modalNode) return;

  const focusable = modalNode.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0] as HTMLElement;
  const last = focusable[focusable.length - 1] as HTMLElement;

  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Escape') handleClose();
    if (e.key === 'Tab') {
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last?.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first?.focus();
      }
    }
  };

  first?.focus();
  modalNode.addEventListener('keydown', handleKeyDown);
  return () => modalNode.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleClose]);

Full source lives in the app/components/use-cases/booking-widget/ directory.