@anchrd/intel-ui 0.13.0 → 0.14.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -32,7 +32,7 @@ import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
32
32
  import { useI18n } from "@/i18n/i18n-context.tsx";
33
33
  import { Modal } from "@/modal/modal.tsx";
34
34
  import { useIntelRouterContext } from "@/router/router-context.ts";
35
- import { availableTimezones } from "@/timezone/timezone.ts";
35
+ import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
36
36
  import { useTimezone } from "@/timezone/timezone-context.tsx";
37
37
 
38
38
  // No dividers between sections — the space is the separation (#204). The explanation sits behind
@@ -660,8 +660,6 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
660
660
  // means the same hour to everybody else reading the agent.
661
661
  const preferred = useTimezone();
662
662
  const [timezone, setTimezone] = useState(preferred);
663
- const zones = availableTimezones();
664
- const options = zones.includes(timezone) ? zones : [timezone, ...zones];
665
663
  const [target, setTarget] = useState<{ id: string; kind: "document" | "flow" } | null>(null);
666
664
 
667
665
  return (
@@ -692,19 +690,12 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
692
690
  <label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
693
691
  {i18n.t("agent.scheduleTimezone")}
694
692
  </label>
695
- <select
693
+ <TimezoneCombobox
696
694
  id="agent-schedule-timezone"
697
695
  value={timezone}
698
- onChange={(event) => setTimezone(event.target.value)}
699
- aria-describedby="agent-timezone-hint"
700
- className="-mt-2 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
701
- >
702
- {options.map((zone) => (
703
- <option key={zone} value={zone}>
704
- {zone.replace(/_/g, " ")}
705
- </option>
706
- ))}
707
- </select>
696
+ onChange={setTimezone}
697
+ describedBy="agent-timezone-hint"
698
+ />
708
699
  <span id="agent-timezone-hint" className="-mt-2 block text-xs text-muted-foreground">
709
700
  {i18n.t("agent.scheduleTimezoneHint")}
710
701
  </span>
@@ -16,7 +16,7 @@ import { useI18n, useLanguage } from "@/i18n/i18n-context.tsx";
16
16
  import { languageName } from "@/i18n/i18n-languages/i18n-languages.ts";
17
17
  import { type ThemeChoice, ThemeChoices } from "@/theme/theme.ts";
18
18
  import { useTheme } from "@/theme/theme-context.tsx";
19
- import { availableTimezones, browserTimezone } from "@/timezone/timezone.ts";
19
+ import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
20
20
  import { useTimezoneSelection } from "@/timezone/timezone-context.tsx";
21
21
 
22
22
  // The settings that belong to the person rather than to the installation: language, appearance and
@@ -38,18 +38,6 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
38
38
  const theme = useTheme();
39
39
  const timezone = useTimezoneSelection();
40
40
 
41
- // ⚠️ The active zone is put in front of the list unless it is already there, and that is not
42
- // belt-and-braces: `Intl.supportedValuesOf("timeZone")` returns only CANONICAL IANA names, and
43
- // "UTC" is not among them (it is an alias of Etc/UTC). A reader whose browser reports "UTC" —
44
- // every CI machine, plenty of containers and servers — would otherwise get a Select whose value
45
- // matches no item, and Radix renders that as an EMPTY field. Found by CI, not by the test.
46
- //
47
- // The same line covers the engine without `supportedValuesOf` at all: the list is empty and the
48
- // reader still has their own zone to look at.
49
- const zones = availableTimezones();
50
- const current = timezone?.current ?? browserTimezone();
51
- const options = zones.includes(current) ? zones : [current, ...zones];
52
-
53
41
  return (
54
42
  <Dialog open={open} onOpenChange={onOpenChange}>
55
43
  <DialogContent className="sm:max-w-md">
@@ -109,21 +97,15 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
109
97
  <label className="text-sm font-medium" htmlFor="settings-timezone">
110
98
  {i18n.t("settings.timezone")}
111
99
  </label>
112
- <Select value={timezone.current} onValueChange={timezone.select}>
113
- <SelectTrigger id="settings-timezone" className="w-full">
114
- <SelectValue />
115
- </SelectTrigger>
116
- {/* Several hundred zones: the list scrolls, and Radix keeps the selected one in
117
- view when it opens. */}
118
- <SelectContent className="max-h-72">
119
- {options.map((zone) => (
120
- <SelectItem key={zone} value={zone}>
121
- {zone.replace(/_/g, " ")}
122
- </SelectItem>
123
- ))}
124
- </SelectContent>
125
- </Select>
126
- <p className="text-xs text-muted-foreground">{i18n.t("settings.timezoneHint")}</p>
100
+ <TimezoneCombobox
101
+ id="settings-timezone"
102
+ value={timezone.current}
103
+ onChange={timezone.select}
104
+ describedBy="settings-timezone-hint"
105
+ />
106
+ <p id="settings-timezone-hint" className="text-xs text-muted-foreground">
107
+ {i18n.t("settings.timezoneHint")}
108
+ </p>
127
109
  </div>
128
110
  ) : null}
129
111
  </div>
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { Popover as PopoverPrimitive } from "radix-ui";
4
+ import type * as React from "react";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
8
+ return <PopoverPrimitive.Root data-slot="popover" {...props} />;
9
+ }
10
+
11
+ function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
12
+ return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
13
+ }
14
+
15
+ function PopoverContent({
16
+ className,
17
+ align = "center",
18
+ sideOffset = 4,
19
+ ...props
20
+ }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
21
+ return (
22
+ <PopoverPrimitive.Portal>
23
+ <PopoverPrimitive.Content
24
+ data-slot="popover-content"
25
+ align={align}
26
+ sideOffset={sideOffset}
27
+ className={cn(
28
+ "z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
29
+ className,
30
+ )}
31
+ {...props}
32
+ />
33
+ </PopoverPrimitive.Portal>
34
+ );
35
+ }
36
+
37
+ function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
38
+ return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
39
+ }
40
+
41
+ export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
package/src/i18n/de.json CHANGED
@@ -379,6 +379,10 @@
379
379
  "settings.appearance.dark": "Dunkel",
380
380
  "settings.timezone": "Zeitzone",
381
381
  "settings.timezoneHint": "Zum Lesen von Zeiten, und als Vorschlag, wenn du einem Agenten einen Zeitplan gibst. Ein Zeitplan behält die Zeitzone, die er bekommen hat — eine Reise verschiebt also keine Läufe, die du bereits gesetzt hast.",
382
+ "settings.timezoneSearch": "Stadt, Zone oder Versatz suchen …",
383
+ "settings.timezoneNoMatch": "Keine Zeitzone passt.",
384
+ "settings.timezoneOwn": "Dein Gerät",
385
+ "settings.timezoneAll": "Alle Zeitzonen",
382
386
  "auth.signOut": "Abmelden",
383
387
  "auth.signOutFailed": "Das Abmelden ist fehlgeschlagen. Prüfe deine Verbindung und versuche es erneut.",
384
388
  "signIn.refusedTitle": "Angemeldet, aber nicht angenommen",
package/src/i18n/en.json CHANGED
@@ -379,6 +379,10 @@
379
379
  "settings.appearance.dark": "Dark",
380
380
  "settings.timezone": "Timezone",
381
381
  "settings.timezoneHint": "Used to read times, and suggested when you give an agent a schedule. A schedule keeps the timezone it was given, so travelling does not move the runs you already set.",
382
+ "settings.timezoneSearch": "Search city, zone or offset…",
383
+ "settings.timezoneNoMatch": "No timezone matches.",
384
+ "settings.timezoneOwn": "Your device",
385
+ "settings.timezoneAll": "All timezones",
382
386
  "auth.signOut": "Sign out",
383
387
  "auth.signOutFailed": "Signing out failed. Check your connection and try again.",
384
388
  "signIn.refusedTitle": "Signed in, but not accepted",
package/src/i18n/es.json CHANGED
@@ -379,6 +379,10 @@
379
379
  "settings.appearance.dark": "Oscuro",
380
380
  "settings.timezone": "Zona horaria",
381
381
  "settings.timezoneHint": "Se usa para leer las horas y se propone cuando le das un horario a un agente. Un horario conserva la zona con la que se creó, así que viajar no mueve las ejecuciones que ya has fijado.",
382
+ "settings.timezoneSearch": "Buscar ciudad, zona o desfase…",
383
+ "settings.timezoneNoMatch": "Ninguna zona horaria coincide.",
384
+ "settings.timezoneOwn": "Tu dispositivo",
385
+ "settings.timezoneAll": "Todas las zonas horarias",
382
386
  "auth.signOut": "Cerrar sesión",
383
387
  "auth.signOutFailed": "El cierre de sesión ha fallado. Comprueba tu conexión e inténtalo de nuevo.",
384
388
  "signIn.refusedTitle": "Sesión iniciada, pero no aceptada",
@@ -0,0 +1,149 @@
1
+ import { Check, ChevronsUpDown } from "lucide-react";
2
+ import { useMemo, useState } from "react";
3
+ import {
4
+ Command,
5
+ CommandEmpty,
6
+ CommandGroup,
7
+ CommandInput,
8
+ CommandItem,
9
+ CommandList,
10
+ } from "@/components/ui/command";
11
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
12
+ import { useI18n } from "@/i18n/i18n-context.tsx";
13
+ import { cn } from "@/lib/utils";
14
+ import {
15
+ browserTimezone,
16
+ type TimezoneOption,
17
+ timezoneOption,
18
+ timezoneOptions,
19
+ } from "../timezone.ts";
20
+
21
+ /**
22
+ * Picking one of 418 timezones.
23
+ *
24
+ * ⚠️ A `Select` was the wrong shape and this replaces it (#243). Four hundred entries cannot be
25
+ * scrolled to, and a bare zone name does not say whether you got the right one — `Europe/Berlin`
26
+ * and `Europe/Madrid` are the same clock, `America/New_York` is not. So: type to search, and the
27
+ * current offset stands in front of every name.
28
+ *
29
+ * ⚠️ The label is computed here, at render time, and never stored. A zone's offset AND its
30
+ * abbreviation move with daylight saving, so a written-down label is wrong for half of every year.
31
+ */
32
+ interface TimezoneComboboxProps {
33
+ id: string;
34
+ value: string;
35
+ onChange(zone: string): void;
36
+ /** Handed in rather than read from the clock, so a test does not have to travel in time. */
37
+ now?: Date;
38
+ describedBy?: string;
39
+ }
40
+
41
+ function OptionLabel({ option }: { option: TimezoneOption }) {
42
+ return (
43
+ <>
44
+ {/* Fixed width, so the zone names line up into a column instead of stepping left and right
45
+ with every offset. Tabular figures keep "GMT+05:30" and "GMT-04:00" the same width, and
46
+ ⚠️ `whitespace-nowrap` is not decoration: the longest label ("GMT-09:00 · HADT") wrapped
47
+ onto a second line and pushed its own row out of the column. */}
48
+ <span className="w-36 shrink-0 whitespace-nowrap text-xs tabular-nums text-muted-foreground">
49
+ {option.offsetLabel}
50
+ {option.abbreviation ? ` · ${option.abbreviation}` : ""}
51
+ </span>
52
+ <span className="truncate">{option.zone.replace(/_/g, " ")}</span>
53
+ </>
54
+ );
55
+ }
56
+
57
+ export function TimezoneCombobox({ id, value, onChange, now, describedBy }: TimezoneComboboxProps) {
58
+ const i18n = useI18n();
59
+ const [open, setOpen] = useState(false);
60
+ const at = now ?? new Date();
61
+ // The clock is a dependency on purpose: without it in the key, a session that crossed a DST
62
+ // boundary would keep showing yesterday's offsets.
63
+ const at_ = at.getTime();
64
+
65
+ const options = useMemo(
66
+ () => timezoneOptions(value, new Date(at_), i18n.locale),
67
+ [value, at_, i18n.locale],
68
+ );
69
+ const selected = useMemo(
70
+ () => timezoneOption(value, new Date(at_), i18n.locale),
71
+ [value, at_, i18n.locale],
72
+ );
73
+ // The reader's own zone, pinned above the list: it is the answer in the overwhelming majority of
74
+ // cases, and finding it in a sorted list of 418 is the work this saves.
75
+ const own = useMemo(
76
+ () => timezoneOption(browserTimezone(), new Date(at_), i18n.locale),
77
+ [at_, i18n.locale],
78
+ );
79
+
80
+ const choose = (zone: string) => {
81
+ onChange(zone);
82
+ setOpen(false);
83
+ };
84
+
85
+ return (
86
+ <Popover open={open} onOpenChange={setOpen}>
87
+ <PopoverTrigger asChild>
88
+ <button
89
+ id={id}
90
+ type="button"
91
+ role="combobox"
92
+ aria-expanded={open}
93
+ aria-describedby={describedBy}
94
+ className="flex w-full items-center gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
95
+ >
96
+ <OptionLabel option={selected} />
97
+ <ChevronsUpDown aria-hidden="true" className="ml-auto size-4 shrink-0 opacity-50" />
98
+ </button>
99
+ </PopoverTrigger>
100
+ <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
101
+ {/* ⚠️ A substring filter, not cmdk's default fuzzy one. Fuzzy matches a scattered
102
+ subsequence, so over 418 zones "berlin" also surfaced Brisbane, Dublin and Lisbon —
103
+ technically a match, useless as an answer. Prefix beats contains, so typing a city puts
104
+ that city first. An empty search returns 1 for everything, which leaves the list in DOM
105
+ order: sorted west to east. */}
106
+ <Command
107
+ filter={(value, search, keywords) => {
108
+ const needle = search.trim().toLowerCase();
109
+ if (!needle) return 1;
110
+ const haystack = [value.toLowerCase(), ...(keywords ?? [])];
111
+ if (haystack.some((word) => word.startsWith(needle))) return 2;
112
+ return haystack.some((word) => word.includes(needle)) ? 1 : 0;
113
+ }}
114
+ >
115
+ <CommandInput placeholder={i18n.t("settings.timezoneSearch")} />
116
+ <CommandList>
117
+ <CommandEmpty>{i18n.t("settings.timezoneNoMatch")}</CommandEmpty>
118
+ {own.zone !== value ? (
119
+ <CommandGroup heading={i18n.t("settings.timezoneOwn")}>
120
+ <CommandItem value={own.zone} keywords={own.keywords} onSelect={choose}>
121
+ <OptionLabel option={own} />
122
+ </CommandItem>
123
+ </CommandGroup>
124
+ ) : null}
125
+ <CommandGroup heading={i18n.t("settings.timezoneAll")}>
126
+ {options.map((option) => (
127
+ <CommandItem
128
+ key={option.zone}
129
+ value={option.zone}
130
+ keywords={option.keywords}
131
+ onSelect={choose}
132
+ >
133
+ <OptionLabel option={option} />
134
+ <Check
135
+ aria-hidden="true"
136
+ className={cn(
137
+ "ml-auto size-4 shrink-0",
138
+ option.zone === value ? "opacity-100" : "opacity-0",
139
+ )}
140
+ />
141
+ </CommandItem>
142
+ ))}
143
+ </CommandGroup>
144
+ </CommandList>
145
+ </Command>
146
+ </PopoverContent>
147
+ </Popover>
148
+ );
149
+ }
@@ -58,3 +58,102 @@ export function rememberTimezone(zone: string, store?: Pick<Storage, "setItem">)
58
58
  // A preference that cannot be written is still a preference for this session.
59
59
  }
60
60
  }
61
+
62
+ /** One zone as the picker shows it. Everything here is derived, nothing is stored. */
63
+ export interface TimezoneOption {
64
+ zone: string;
65
+ /** "GMT+02:00", "GMT-04:00", "GMT" — present for every zone, which is why it carries the column. */
66
+ offsetLabel: string;
67
+ /** "MESZ", "JST" … or null where the runtime only repeats the offset. */
68
+ abbreviation: string | null;
69
+ /** Minutes east of UTC at `at`, for sorting. */
70
+ offsetMinutes: number;
71
+ /** What the search matches on, lowercased. */
72
+ keywords: string[];
73
+ }
74
+
75
+ function part(zone: string, style: "short" | "longOffset", at: Date, locale: string): string {
76
+ try {
77
+ return (
78
+ new Intl.DateTimeFormat(locale, { timeZone: zone, timeZoneName: style })
79
+ .formatToParts(at)
80
+ .find((piece) => piece.type === "timeZoneName")?.value ?? ""
81
+ );
82
+ } catch {
83
+ return "";
84
+ }
85
+ }
86
+
87
+ // "GMT+02:00" → 120, "GMT-04:00" → -240, "GMT" → 0. Read from the same string the label shows, so
88
+ // the sort order and the text can never tell different stories.
89
+ function minutesFrom(offsetLabel: string): number {
90
+ const match = /^GMT([+-])(\d{2}):(\d{2})$/.exec(offsetLabel);
91
+ if (!match) return 0;
92
+ const [, sign, hours, minutes] = match;
93
+ const total = Number(hours) * 60 + Number(minutes);
94
+ return sign === "-" ? -total : total;
95
+ }
96
+
97
+ /**
98
+ * The label of one zone, at a given moment.
99
+ *
100
+ * ⚠️ Computed at display time and never stored. A zone's offset AND its abbreviation move with
101
+ * daylight saving — Berlin is MEZ/+01:00 in January and MESZ/+02:00 in July — so a label written
102
+ * into a field would be wrong for half of every year.
103
+ *
104
+ * ⚠️ The abbreviation is dropped where `Intl` only echoes the offset — and WHICH zones have one
105
+ * depends on the READER'S LANGUAGE, not on the zone. CLDR ships short names only where a locale has
106
+ * its own word for that zone: a German reader gets "MEZ/MESZ" for Berlin and a bare "GMT-4" for New
107
+ * York; an English reader gets "EST/EDT" for New York and a bare "GMT+2" for Berlin. That is the
108
+ * useful way round — you get the abbreviation for the zones your language actually names — but it
109
+ * means this function's output is not the same in two languages, and a test has to say which one it
110
+ * is asserting. `Asia/Kolkata` has none in either ("GMT+5:30"), which is the common case worldwide.
111
+ */
112
+ export function timezoneOption(zone: string, at: Date, locale: string): TimezoneOption {
113
+ const offsetLabel = part(zone, "longOffset", at, locale) || "GMT";
114
+ const short = part(zone, "short", at, locale);
115
+ // Dropped when it repeats the offset, and also when it repeats the zone's own name: "UTC" would
116
+ // otherwise render as "GMT · UTC UTC", which stutters without adding anything.
117
+ const abbreviation =
118
+ short && !short.startsWith("GMT") && short !== offsetLabel && short !== zone ? short : null;
119
+ const city = zone.split("/").pop()?.replace(/_/g, " ") ?? zone;
120
+ return {
121
+ zone,
122
+ offsetLabel,
123
+ abbreviation,
124
+ offsetMinutes: minutesFrom(offsetLabel),
125
+ // The offset appears twice on purpose: as written ("GMT+02:00") and as typed ("+2"), because
126
+ // nobody searches for the leading zero.
127
+ keywords: [zone, city, offsetLabel, abbreviation ?? "", shorthandOffset(offsetLabel)]
128
+ .filter(Boolean)
129
+ .map((word) => word.toLowerCase()),
130
+ };
131
+ }
132
+
133
+ // "GMT+02:00" → "+2", "GMT+05:30" → "+5:30", "GMT" → "+0". What a person types.
134
+ function shorthandOffset(offsetLabel: string): string {
135
+ const match = /^GMT([+-])(\d{2}):(\d{2})$/.exec(offsetLabel);
136
+ if (!match) return "+0";
137
+ const [, sign, hours, minutes] = match;
138
+ return `${sign}${Number(hours)}${minutes === "00" ? "" : `:${minutes}`}`;
139
+ }
140
+
141
+ /**
142
+ * Every selectable zone, sorted west to east.
143
+ *
144
+ * ⚠️ `current` is folded in even when `supportedValuesOf` does not list it. That is not a corner
145
+ * case: the list holds only CANONICAL names, and "UTC" is not one of them (it is an alias of
146
+ * Etc/UTC) — so the zone every CI machine and many servers report would otherwise be missing from
147
+ * the picker that is supposed to be showing it.
148
+ */
149
+ export function timezoneOptions(current: string, at: Date, locale: string): TimezoneOption[] {
150
+ const zones = availableTimezones();
151
+ const all = zones.includes(current) ? zones : [current, ...zones];
152
+ return all
153
+ .map((zone) => timezoneOption(zone, at, locale))
154
+ .sort((left, right) =>
155
+ left.offsetMinutes === right.offsetMinutes
156
+ ? left.zone.localeCompare(right.zone)
157
+ : left.offsetMinutes - right.offsetMinutes,
158
+ );
159
+ }