fix: searchable timezone, flag dropdown for language
- Bring back the dropdown UX for the language picker (the v1.99 button
grid was nicer at first but lost the at-a-glance affordance the user
preferred). New SearchableDropdown component renders a real dropdown
trigger + list, with each option carrying its inline-SVG flag.
- Timezone picker is now searchable. Match against IANA name, code
(CET, ET…), city/country label, and offset in multiple notations
("UTC+1", "+01:00", "GMT+01:00"). Used both for the user's primary
timezone and the additional-timezones add picker.
v1.101.1
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d3a40e32a0
commit
8c0f7acfd8
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.101.0",
|
||||
"version": "1.101.1",
|
||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
201
src/components/SearchableDropdown.tsx
Normal file
201
src/components/SearchableDropdown.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, Search, X } from "lucide-react";
|
||||
|
||||
// Generic select-like dropdown that supports arbitrary node rendering for
|
||||
// each option (so we can show flags, offsets, etc.) and an optional search
|
||||
// box that filters the list against `searchHaystack`.
|
||||
|
||||
export interface DropdownOption<T = string> {
|
||||
value: T;
|
||||
label: string;
|
||||
searchHaystack?: string; // extra text to match search against (city, country, abbreviation…)
|
||||
leading?: React.ReactNode;
|
||||
secondary?: string; // dimmer text after the label
|
||||
}
|
||||
|
||||
interface SearchableDropdownProps<T = string> {
|
||||
value: T;
|
||||
options: DropdownOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
width?: string | number;
|
||||
darkMode?: boolean;
|
||||
}
|
||||
|
||||
export default function SearchableDropdown<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder,
|
||||
searchable = false,
|
||||
searchPlaceholder = "Search…",
|
||||
emptyText = "No matches",
|
||||
className,
|
||||
style,
|
||||
width = "100%",
|
||||
darkMode = false,
|
||||
}: SearchableDropdownProps<T>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && searchable) setTimeout(() => searchRef.current?.focus(), 30);
|
||||
}, [open, searchable]);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return options;
|
||||
const q = query.toLowerCase();
|
||||
return options.filter((o) => {
|
||||
const hay = (o.searchHaystack || `${o.label} ${o.secondary || ""}`).toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}, [options, query]);
|
||||
|
||||
const bg = darkMode ? "#1f2937" : "#ffffff";
|
||||
const border = darkMode ? "#374151" : "#d1d5db";
|
||||
const text = darkMode ? "#e5e7eb" : "#111827";
|
||||
const muted = darkMode ? "#9ca3af" : "#6b7280";
|
||||
const hoverBg = darkMode ? "#374151" : "#f3f4f6";
|
||||
const activeBg = darkMode ? "rgba(13,148,136,0.18)" : "rgba(13,148,136,0.08)";
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className={className} style={{ position: "relative", width, ...style }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${border}`,
|
||||
background: bg,
|
||||
color: text,
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{selected?.leading && <span style={{ display: "inline-flex" }}>{selected.leading}</span>}
|
||||
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{selected ? selected.label : (placeholder || "Select…")}
|
||||
</span>
|
||||
{selected?.secondary && (
|
||||
<span style={{ color: muted, fontSize: "0.8rem", whiteSpace: "nowrap" }}>{selected.secondary}</span>
|
||||
)}
|
||||
<ChevronDown size={14} style={{ color: muted, flexShrink: 0, transform: open ? "rotate(180deg)" : "none", transition: "transform 0.15s" }} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 4px)",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
background: bg,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxHeight: 320,
|
||||
}}
|
||||
>
|
||||
{searchable && (
|
||||
<div style={{ padding: 8, borderBottom: `1px solid ${border}`, display: "flex", alignItems: "center", gap: 6, background: bg }}>
|
||||
<Search size={14} style={{ color: muted, flexShrink: 0 }} />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
color: text,
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery("")}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: muted, padding: 2 }}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: 16, color: muted, fontSize: "0.85rem", textAlign: "center" }}>{emptyText}</div>
|
||||
) : filtered.map((opt) => {
|
||||
const active = opt.value === value;
|
||||
return (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 10px",
|
||||
border: "none",
|
||||
background: active ? activeBg : "transparent",
|
||||
color: text,
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = hoverBg; }}
|
||||
onMouseLeave={(e) => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = "transparent"; }}
|
||||
>
|
||||
{opt.leading && <span style={{ display: "inline-flex", flexShrink: 0 }}>{opt.leading}</span>}
|
||||
<span style={{ flex: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{opt.label}</span>
|
||||
{opt.secondary && <span style={{ color: muted, fontSize: "0.78rem", whiteSpace: "nowrap" }}>{opt.secondary}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -36,7 +36,8 @@ import IconPicker from "./IconPicker";
|
||||
import { allIcons } from "./iconRegistry";
|
||||
import Icon from "@mdi/react";
|
||||
import FlagIcon from "./FlagIcon";
|
||||
import { TIMEZONE_OPTIONS, formatTimezone } from "../lib/timezones";
|
||||
import SearchableDropdown from "./SearchableDropdown";
|
||||
import { TIMEZONE_OPTIONS, formatTimezone, getOffsetMinutes, formatOffset } from "../lib/timezones";
|
||||
|
||||
// Punkt 6 — manage a user's extra timezones (for cross-team scheduling).
|
||||
// Stored in user.viewSettings.extraTimezones to avoid an extra migration.
|
||||
@ -104,17 +105,33 @@ function ExtraTimezonesEditor({
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "6px" }}>
|
||||
<select
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<SearchableDropdown
|
||||
value={picker}
|
||||
onChange={(e) => setPicker(e.target.value)}
|
||||
className="weekly-input"
|
||||
style={{ flex: 1, padding: "6px 8px", border: "1px solid var(--weekly-border, #e5e7eb)", borderRadius: "6px", fontSize: "0.85rem" }}
|
||||
>
|
||||
<option value="">{de ? "Zeitzone auswählen…" : "Select a timezone…"}</option>
|
||||
{TIMEZONE_OPTIONS.filter((o) => !extras.includes(o.zone) && o.zone !== profile.timezone).map((opt) => (
|
||||
<option key={opt.zone} value={opt.zone}>{formatTimezone(opt)}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setPicker(v)}
|
||||
searchable
|
||||
placeholder={de ? "Zeitzone auswählen…" : "Select a timezone…"}
|
||||
searchPlaceholder={de ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
|
||||
emptyText={de ? "Keine Treffer" : "No matches"}
|
||||
options={TIMEZONE_OPTIONS
|
||||
.filter((o) => !extras.includes(o.zone) && o.zone !== profile.timezone)
|
||||
.sort((a, b) => {
|
||||
const oa = getOffsetMinutes(a.zone);
|
||||
const ob = getOffsetMinutes(b.zone);
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.code.localeCompare(b.code);
|
||||
})
|
||||
.map((opt) => {
|
||||
const off = formatOffset(getOffsetMinutes(opt.zone));
|
||||
return {
|
||||
value: opt.zone,
|
||||
label: `${opt.code} — ${opt.label}`,
|
||||
secondary: `UTC${off}`,
|
||||
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => add(picker)}
|
||||
disabled={!picker}
|
||||
@ -1690,51 +1707,17 @@ function SettingsSidebar({
|
||||
>
|
||||
{t.language}
|
||||
</label>
|
||||
<div
|
||||
role="radiogroup"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "fr", label: "Français" },
|
||||
{ code: "es", label: "Español" },
|
||||
{ code: "it", label: "Italiano" },
|
||||
].map((lang) => {
|
||||
const active = (profile.language || "de") === lang.code;
|
||||
return (
|
||||
<button
|
||||
key={lang.code}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => saveField("language", lang.code)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "8px 10px",
|
||||
borderRadius: "6px",
|
||||
border: active
|
||||
? "2px solid var(--weekly-teal, #009a9a)"
|
||||
: "1px solid var(--weekly-border, #e5e7eb)",
|
||||
background: active ? "rgba(13,148,136,0.08)" : "var(--weekly-bg, #fff)",
|
||||
color: "var(--weekly-text, #333)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: active ? 600 : 400,
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<FlagIcon code={lang.code} width={20} height={14} />
|
||||
<span>{lang.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SearchableDropdown
|
||||
value={profile.language || "de"}
|
||||
onChange={(v) => saveField("language", v)}
|
||||
options={[
|
||||
{ value: "en", label: "English", leading: <FlagIcon code="en" width={20} height={14} /> },
|
||||
{ value: "de", label: "Deutsch", leading: <FlagIcon code="de" width={20} height={14} /> },
|
||||
{ value: "fr", label: "Français", leading: <FlagIcon code="fr" width={20} height={14} /> },
|
||||
{ value: "es", label: "Español", leading: <FlagIcon code="es" width={20} height={14} /> },
|
||||
{ value: "it", label: "Italiano", leading: <FlagIcon code="it" width={20} height={14} /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -1748,33 +1731,31 @@ function SettingsSidebar({
|
||||
>
|
||||
{t.timezone}
|
||||
</label>
|
||||
<select
|
||||
<SearchableDropdown
|
||||
value={profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
onChange={(e) => saveField("timezone", e.target.value)}
|
||||
className="weekly-input"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
border: "1px solid var(--weekly-settings-input-border, #ddd)",
|
||||
borderRadius: "4px",
|
||||
background: "var(--weekly-settings-input-bg, #fff)",
|
||||
color: "var(--weekly-settings-text, #333)",
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const sorted = [...TIMEZONE_OPTIONS].sort((a, b) => {
|
||||
const oa = new Date().toLocaleString("en-US", { timeZone: a.zone, timeZoneName: "shortOffset" });
|
||||
const ob = new Date().toLocaleString("en-US", { timeZone: b.zone, timeZoneName: "shortOffset" });
|
||||
return oa.localeCompare(ob);
|
||||
});
|
||||
// Use formatTimezone for label so each option shows offset + code + label
|
||||
return sorted.map((opt) => (
|
||||
<option key={opt.zone} value={opt.zone}>
|
||||
{formatTimezone(opt)}
|
||||
</option>
|
||||
));
|
||||
})()}
|
||||
</select>
|
||||
onChange={(v) => saveField("timezone", v)}
|
||||
searchable
|
||||
searchPlaceholder={profile.language === "de" ? "Stadt, Land, UTC, CET…" : "City, country, UTC, CET…"}
|
||||
emptyText={profile.language === "de" ? "Keine Treffer" : "No matches"}
|
||||
options={[...TIMEZONE_OPTIONS]
|
||||
.sort((a, b) => {
|
||||
const oa = getOffsetMinutes(a.zone);
|
||||
const ob = getOffsetMinutes(b.zone);
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.code.localeCompare(b.code);
|
||||
})
|
||||
.map((opt) => {
|
||||
const off = formatOffset(getOffsetMinutes(opt.zone));
|
||||
return {
|
||||
value: opt.zone,
|
||||
label: `${opt.code} — ${opt.label}`,
|
||||
secondary: `UTC${off}`,
|
||||
// Match against IANA name (zone), code, label,
|
||||
// and the offset in several styles.
|
||||
searchHaystack: `${opt.zone} ${opt.code} ${opt.label} utc${off} utc${off.replace(":00", "")} gmt${off}`,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
{/* Multi-timezone (Punkt 6) */}
|
||||
<ExtraTimezonesEditor
|
||||
profile={profile}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user