/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { buildClockZone, buildLocalizedTimeZoneMap, getCityFromTimeZone, getClockFormDerivedState, getRandomLabelColor, } from "./ClocksHelpers"; const MAX_NICKNAME_LENGTH = 11; /** * Add/edit form for a single clock. Owns its own form state — the parent * only knows whether the form is open (mount/unmount toggle), the clock * being edited (if any), and what to do with the saved zone. * * @param {object} props * @param {boolean} props.isEditing * @param {object|null} props.initialClock Pre-fill values when editing. * @param {boolean} props.canAddClock * @param {string[]} props.supportedTimeZones * @param {string} [props.locale] Locale for localized zone names. * @param {(zone: object) => void} props.onSave * @param {() => void} props.onCancel */ export function AddClockForm({ isEditing, initialClock, canAddClock, supportedTimeZones, locale, onSave, onCancel, }) { const localizedTimeZoneMap = useMemo( () => buildLocalizedTimeZoneMap(supportedTimeZones, locale), [supportedTimeZones, locale] ); const [searchQuery, setSearchQuery] = useState( initialClock ? initialClock.city || getCityFromTimeZone(initialClock.timeZone) : "" ); const [selectedTimeZone, setSelectedTimeZone] = useState( initialClock?.timeZone || "" ); const [nickname, setNickname] = useState(initialClock?.label || ""); const searchInputRef = useRef(null); const { canAddSelectedClock, filteredTimeZones, resolvedClockTimeZone, showLocationDropdown, } = useMemo( () => getClockFormDerivedState({ canAddClock, clockSearchQuery: searchQuery, clockSelectedTimeZone: selectedTimeZone, isEditingClock: isEditing, localizedTimeZoneMap, supportedTimeZones, }), [ canAddClock, searchQuery, selectedTimeZone, isEditing, localizedTimeZoneMap, supportedTimeZones, ] ); // moz-input-search renders its inner input asynchronously, so focusing // the custom element host immediately can throw before inputEl exists. useEffect(() => { let frameId = 0; let remainingFrames = 5; const focusWhenReady = () => { const input = searchInputRef.current?.inputEl; if (input) { input.focus(); return; } if (remainingFrames > 0) { remainingFrames -= 1; frameId = requestAnimationFrame(focusWhenReady); } }; frameId = requestAnimationFrame(focusWhenReady); return () => cancelAnimationFrame(frameId); }, []); const handleSelectLocation = useCallback(timeZone => { setSearchQuery(getCityFromTimeZone(timeZone)); setSelectedTimeZone(timeZone); }, []); const handleNicknameInput = useCallback(e => { setNickname(e.target.value.slice(0, MAX_NICKNAME_LENGTH)); }, []); const handleSubmit = useCallback(() => { if (!canAddSelectedClock) { return; } const trimmed = nickname.trim(); const label = trimmed ? trimmed.slice(0, MAX_NICKNAME_LENGTH) : null; // Preserve existing labelColor when editing the same zone so an // unchanged labeled clock keeps its color across edits. const baseZone = initialClock && initialClock.timeZone === resolvedClockTimeZone ? { ...initialClock } : buildClockZone(resolvedClockTimeZone); onSave({ ...baseZone, label, labelColor: label ? baseZone.labelColor || getRandomLabelColor() : null, }); }, [ canAddSelectedClock, nickname, initialClock, resolvedClockTimeZone, onSave, ]); return (
{ e.preventDefault(); handleSubmit(); }} onKeyDown={e => { if (e.key === "Escape") { onCancel(); } else if ( e.key === "Enter" && !e.target.closest(".clocks-search-result") && !e.target.closest("moz-button, button") ) { e.preventDefault(); handleSubmit(); } }} onBlur={e => { if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget)) { onCancel(); } }} >
{ setSearchQuery(e.target.value); setSelectedTimeZone(""); }} /> {showLocationDropdown && (
{filteredTimeZones.length ? ( filteredTimeZones.map((timeZone, index) => (
handleSelectLocation(timeZone)} onKeyDown={e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleSelectLocation(timeZone); } }} role="option" aria-selected={timeZone === selectedTimeZone} tabIndex={0} > {getCityFromTimeZone(timeZone)} {localizedTimeZoneMap?.get(timeZone) || timeZone}
)) ) : (
)}
)}
); }