@brftech/filex-core 0.39.1 → 0.41.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.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +43 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -0,0 +1,696 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * TimeZonePicker — a search box that opens a ranked list of zones.
4
+ *
5
+ * ⚠⚠ ONE picker, two hosts. It was written inside the web app's user-settings
6
+ * modal (`web/src/components/UserSettingsModal.vue`), and then the embed
7
+ * needed the same control — an embed has no settings dialog, so its "⋯" menu
8
+ * opens `TimeZoneDialog`, which mounts this. `packages/core` cannot import from
9
+ * `web/`, so the picker moved here and both surfaces render it from this one
10
+ * definition. A second copy would be the duplication the owner has ruled out
11
+ * (*"tekrar eden şeyler tek yerden eklenmeli"*) and the first thing the two
12
+ * would disagree about is which zones they find.
13
+ *
14
+ * Presentational: `modelValue` is `''` or an IANA id, a pick emits
15
+ * `update:modelValue`, and the HOST decides what a pick writes — the web app
16
+ * writes the account, the embed writes this browser's own choice
17
+ * (`lib/timezone`, tier `viewer`). What `''` stands for differs between them,
18
+ * so the host says it through `fallback` and the first row names it.
19
+ *
20
+ * ⚠ A half-typed string never becomes the value: `query` (what you typed) and
21
+ * `modelValue` (what is in force) are separate, and only `commit` crosses
22
+ * between them.
23
+ */
24
+ import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
25
+ import type { LocaleCode } from '../types/ExplorerConfig';
26
+ import { localeTag, useLocale } from '../composables/useLocale';
27
+ import {
28
+ deviceTimeZone,
29
+ supportedTimeZones,
30
+ type ResolvedTimeZone,
31
+ } from '../lib/timezone';
32
+
33
+ const props = defineProps<{
34
+ locale: LocaleCode;
35
+ /** `''` = the default row; otherwise an IANA id. */
36
+ modelValue: string;
37
+ /** What `''` resolves to on this surface — the zone and the tier. */
38
+ fallback: ResolvedTimeZone;
39
+ disabled?: boolean;
40
+ /** Id for the input, so the host's own `<label for>` names the control. */
41
+ inputId?: string;
42
+ /** Accessible name for the list (the host's visible label, usually). */
43
+ label?: string;
44
+ /** `data-testid` prefix: `-filter`, `-list`, `-opt-<zone|device>`. */
45
+ testid?: string;
46
+ }>();
47
+
48
+ const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>();
49
+
50
+ const { t } = useLocale(() => props.locale);
51
+ const tag = computed(() => localeTag(props.locale));
52
+ const tid = computed(() => props.testid || 'fe-tz');
53
+
54
+ let seq = 0;
55
+ const uid = `fe-tz-${++seq}-${Math.random().toString(36).slice(2, 7)}`;
56
+ const inputDomId = computed(() => props.inputId || `${uid}-input`);
57
+ const listId = `${uid}-list`;
58
+
59
+ /* ── the zone index ──────────────────────────────────────────────────────
60
+ *
61
+ * ⚠⚠ NOT a hand-written list of zones. The ids come from the platform
62
+ * (`Intl.supportedValuesOf('timeZone')`, wrapped by `supportedTimeZones()`,
63
+ * which falls back to a small canonical set on an engine that answers
64
+ * nothing — so a person on such a browser still gets a usable list and, above
65
+ * all, KEEPS the zone they already have).
66
+ *
67
+ * Everything a row shows or is searched by is derived from `Intl`:
68
+ *
69
+ * • the offset — `timeZoneName: 'shortOffset'` → "GMT+03:00";
70
+ * • the region name — `timeZoneName: 'longGeneric'` → "Turkey Time", built
71
+ * in BOTH English and the interface language, so "turkey" and "Türkiye"
72
+ * both find `Europe/Istanbul` whichever catalogue is loaded. That one line
73
+ * is why the alias table below is eight entries and not four hundred;
74
+ * • the city — the id's own last segment.
75
+ *
76
+ * Built lazily, the first time the field is opened: ~420 zones × two
77
+ * formatters is work worth doing zero times for the people who never touch
78
+ * this control. Re-built when the language or the default row changes.
79
+ */
80
+ interface TzRow {
81
+ /** The value that gets emitted. `''` = the default row. */
82
+ value: string;
83
+ /** The city, or the default sentence. What the eye lands on. */
84
+ label: string;
85
+ /** "Europe" — drawn muted beside the city. */
86
+ region: string;
87
+ /** "GMT+03:00". */
88
+ offset: string;
89
+ /** Folded text the query is matched against. */
90
+ haystack: string;
91
+ /** The city alone, folded — the strongest thing a query can hit. */
92
+ cityFold: string;
93
+ /** Every word of the haystack, folded — for "starts a word" matches. */
94
+ words: string[];
95
+ /** Clock for this zone, or null when the engine refuses the id. */
96
+ fmt: Intl.DateTimeFormat | null;
97
+ }
98
+
99
+ /**
100
+ * Fold a string down to something a person's typing can be compared against:
101
+ * case, Turkish letters, accents and the id's punctuation all removed.
102
+ *
103
+ * ⚠ The Turkish map is explicit and comes FIRST. `'İ'.toLowerCase()` is
104
+ * "i̇" (an i plus a combining dot) in every locale-independent lowercase,
105
+ * and `'I'.toLowerCase()` is "i" — so a person typing İstanbul on a Turkish
106
+ * keyboard and the id `Europe/Istanbul` do not meet unless the dot is dealt
107
+ * with by hand. Stripping combining marks afterwards catches the rest.
108
+ */
109
+ function fold(input: string): string {
110
+ return input
111
+ .replace(/[\u0130\u0131]/g, 'i')
112
+ .replace(/[\u015e\u015f]/g, 's')
113
+ .replace(/[\u011e\u011f]/g, 'g')
114
+ .replace(/[\u00dc\u00fc]/g, 'u')
115
+ .replace(/[\u00d6\u00f6]/g, 'o')
116
+ .replace(/[\u00c7\u00e7]/g, 'c')
117
+ .toLowerCase()
118
+ .normalize('NFD')
119
+ .replace(/[\u0300-\u036f]/g, '')
120
+ .replace(/[_/\-.]+/g, ' ')
121
+ .replace(/\s+/g, ' ')
122
+ .trim();
123
+ }
124
+
125
+ /**
126
+ * The handful of names `Intl` does not say.
127
+ *
128
+ * ⚠ Deliberately tiny, and deliberately not a zone list: the generic names
129
+ * already carry the country for every zone that has one. These are the words
130
+ * people type that are not a city, a country or an offset — mostly the
131
+ * abbreviations a support ticket arrives written in.
132
+ */
133
+ const ALIASES: Record<string, string> = {
134
+ UTC: 'utc gmt zulu z greenwich universal',
135
+ 'Etc/UTC': 'utc gmt zulu greenwich universal',
136
+ 'Etc/GMT': 'utc gmt greenwich',
137
+ 'Europe/Istanbul': 'turkey turkiye ist constantinople',
138
+ 'Europe/London': 'uk britain england gmt bst',
139
+ 'America/New_York': 'usa united states eastern est edt nyc',
140
+ 'America/Los_Angeles': 'usa united states pacific pst pdt la',
141
+ 'Asia/Kolkata': 'india calcutta ist',
142
+ };
143
+
144
+ /** Every way a person might type an offset the platform prints as "GMT+03:00". */
145
+ function offsetAliases(offset: string): string {
146
+ const m = /GMT([+-])(\d{1,2})(?::?(\d{2}))?/.exec(offset);
147
+ if (!m) return 'gmt utc';
148
+ const [, sign, hRaw, minRaw] = m;
149
+ const h = String(Number(hRaw));
150
+ const hh = h.padStart(2, '0');
151
+ const mm = minRaw ?? '00';
152
+ const forms = [`${sign}${h}`, `${sign}${hh}`, `${sign}${hh}:${mm}`, `${sign}${hh}${mm}`];
153
+ return [...forms.map((f) => `gmt${f}`), ...forms.map((f) => `utc${f}`), ...forms].join(' ');
154
+ }
155
+
156
+ /** The `timeZoneName` part alone, e.g. "GMT+03:00" or "Turkey Time". */
157
+ function namePart(zone: string, locale: string, style: 'shortOffset' | 'longGeneric'): string {
158
+ try {
159
+ const parts = new Intl.DateTimeFormat(locale, {
160
+ timeZone: zone,
161
+ timeZoneName: style,
162
+ }).formatToParts(new Date());
163
+ return parts.find((x) => x.type === 'timeZoneName')?.value ?? '';
164
+ } catch {
165
+ return '';
166
+ }
167
+ }
168
+
169
+ function clockFor(zone: string): Intl.DateTimeFormat | null {
170
+ try {
171
+ return new Intl.DateTimeFormat(tag.value, { timeZone: zone, hour: '2-digit', minute: '2-digit' });
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /** The zone `''` stands for, spelled out (the device's name when it is the device). */
178
+ const fallbackZone = computed(() => props.fallback.zone ?? deviceTimeZone());
179
+
180
+ /**
181
+ * The first row's sentence. It names the TIER as well as the zone, because
182
+ * "use the default" is a promise about what happens next and the person
183
+ * choosing it should not have to guess whose default that is.
184
+ */
185
+ const defaultLabel = computed(() => {
186
+ const zone = fallbackZone.value;
187
+ if (props.fallback.tier === 'host') return t('tz.default.host', { zone });
188
+ if (props.fallback.tier === 'account') return t('tz.default.account', { zone });
189
+ return t('tz.default.device', { zone });
190
+ });
191
+
192
+ const index = ref<TzRow[] | null>(null);
193
+ let indexKey = '';
194
+
195
+ function buildIndex(): TzRow[] {
196
+ const loc = tag.value;
197
+ const fz = fallbackZone.value;
198
+ const defaultHay = fold(
199
+ `${fz} ${defaultLabel.value} default automatic device local system account site`,
200
+ );
201
+ const rows: TzRow[] = [
202
+ {
203
+ value: '',
204
+ label: defaultLabel.value,
205
+ region: '',
206
+ offset: namePart(fz, loc, 'shortOffset'),
207
+ haystack: defaultHay,
208
+ cityFold: fold(fz.split('/').pop() ?? ''),
209
+ words: defaultHay.split(' ').filter(Boolean),
210
+ fmt: clockFor(fz),
211
+ },
212
+ ];
213
+ /* ⚠ The zone in force is guaranteed a row even when the platform's list
214
+ does not contain it — an engine without `Intl.supportedValuesOf` falls
215
+ back to the small canonical set, and somebody whose zone is outside it
216
+ must still see it, find it highlighted, and be able to land back on it
217
+ after wandering the list. Nothing is rewritten either way: only `commit`
218
+ emits. */
219
+ const ids = supportedTimeZones();
220
+ if (props.modelValue && !ids.includes(props.modelValue)) ids.unshift(props.modelValue);
221
+ for (const zone of ids) {
222
+ const segs = zone.split('/');
223
+ const city = (segs[segs.length - 1] ?? zone).replace(/_/g, ' ');
224
+ const region = segs.length > 1 ? segs[0].replace(/_/g, ' ') : '';
225
+ const offset = namePart(zone, loc, 'shortOffset');
226
+ const genericLocal = namePart(zone, loc, 'longGeneric');
227
+ const genericEn = loc === 'en-US' ? '' : namePart(zone, 'en-US', 'longGeneric');
228
+ const haystack = fold(
229
+ [zone, city, region, genericLocal, genericEn, ALIASES[zone] ?? '', offsetAliases(offset)]
230
+ .filter(Boolean)
231
+ .join(' '),
232
+ );
233
+ rows.push({
234
+ value: zone,
235
+ label: city,
236
+ region,
237
+ offset,
238
+ haystack,
239
+ cityFold: fold(city),
240
+ words: haystack.split(' ').filter(Boolean),
241
+ fmt: clockFor(zone),
242
+ });
243
+ }
244
+ return rows;
245
+ }
246
+
247
+ function ensureIndex(): void {
248
+ const key = `${props.locale}|${props.fallback.tier}|${fallbackZone.value}|${props.modelValue}`;
249
+ if (index.value && indexKey === key) return;
250
+ indexKey = key;
251
+ index.value = buildIndex();
252
+ }
253
+
254
+ /* ── the combobox ──────────────────────────────────────────────────────── */
255
+
256
+ const open = ref(false);
257
+ const query = ref('');
258
+ const active = ref(0);
259
+ const inputEl = ref<HTMLInputElement | null>(null);
260
+ const listEl = ref<HTMLElement | null>(null);
261
+
262
+ /** The instant every row's clock is formatted from — one shared tick rather
263
+ * than 50 rows each calling `new Date()` and disagreeing by a millisecond. */
264
+ const tick = ref(new Date());
265
+ let timer: ReturnType<typeof setInterval> | undefined;
266
+ function startTick(): void {
267
+ tick.value = new Date();
268
+ if (!timer) timer = setInterval(() => (tick.value = new Date()), 1000);
269
+ }
270
+ function stopTick(): void {
271
+ if (timer) clearInterval(timer);
272
+ timer = undefined;
273
+ }
274
+ onBeforeUnmount(stopTick);
275
+
276
+ /**
277
+ * Where the list goes and how tall it may be — MEASURED, not assumed.
278
+ *
279
+ * ⚠ The host usually scrolls (a settings pane, a dialog body), so the list
280
+ * cannot escape it: a fixed 264px drop opened straight through the settings
281
+ * modal's footer and the bottom rows were cut off mid-word (measured at
282
+ * 1280×900 with the field near the bottom of Preferences). So the drop takes
283
+ * whichever side of the field has room inside its nearest scrolling ancestor
284
+ * — the viewport when there is none — and never asks for more than that.
285
+ */
286
+ const drop = ref<{ maxHeight: string; above: boolean }>({ maxHeight: '264px', above: false });
287
+
288
+ function scrollBox(el: HTMLElement): { top: number; bottom: number } {
289
+ let p = el.parentElement;
290
+ while (p) {
291
+ const oy = getComputedStyle(p).overflowY;
292
+ if (oy === 'auto' || oy === 'scroll') {
293
+ const r = p.getBoundingClientRect();
294
+ return { top: r.top, bottom: r.bottom };
295
+ }
296
+ p = p.parentElement;
297
+ }
298
+ return { top: 0, bottom: window.innerHeight };
299
+ }
300
+
301
+ function measureDrop(): void {
302
+ const input = inputEl.value;
303
+ if (!input) return;
304
+ const ir = input.getBoundingClientRect();
305
+ const box = scrollBox(input);
306
+ const gap = 12;
307
+ const below = box.bottom - ir.bottom - gap;
308
+ const above = ir.top - box.top - gap;
309
+ /* A floor, because a two-row list is worse than one that overlaps the field
310
+ it belongs to; and a ceiling, because past ~7 rows people type instead of
311
+ scrolling. */
312
+ const MIN = 132;
313
+ const MAX = 264;
314
+ const useAbove = below < MIN && above > below;
315
+ const room = useAbove ? above : below;
316
+ drop.value = {
317
+ maxHeight: `${Math.round(Math.max(MIN, Math.min(MAX, room)))}px`,
318
+ above: useAbove,
319
+ };
320
+ }
321
+
322
+ /** How many rows are drawn. Unfiltered this list is ~420 long and every row
323
+ * carries a live clock; past the first screenful nobody is reading, they are
324
+ * typing. The count that was left out is SAID, so a short list is never
325
+ * mistaken for the whole answer. */
326
+ const LIMIT = 50;
327
+
328
+ /**
329
+ * How well one row answers one query — `-1` when it does not.
330
+ *
331
+ * ⚠⚠ RANKED, not merely filtered. A plain substring match over the haystack
332
+ * is what this had first, and typing "ist" returned Boa V*ist*a, Ashgabat
333
+ * (Turkmen*ist*an Time) and Dushanbe — with Istanbul nowhere in the visible
334
+ * rows. Measured in a real browser before the fix:
335
+ *
336
+ * the city, exactly → it IS the answer
337
+ * the city, as a prefix → "ist" → Istanbul
338
+ * the start of any word → "new y" → New York, "gmt+3" → the +3 zones
339
+ * anywhere at all → kept, but last
340
+ *
341
+ * Every term has to hit something, so two words narrow instead of widening.
342
+ */
343
+ function score(row: TzRow, terms: string[]): number {
344
+ let total = 0;
345
+ for (const term of terms) {
346
+ let best = 0;
347
+ if (row.cityFold === term) best = 120;
348
+ else if (row.cityFold.startsWith(term)) best = 100;
349
+ else if (row.words.some((w) => w.startsWith(term))) best = 70;
350
+ else if (row.haystack.includes(term)) best = 30;
351
+ if (best === 0) return -1;
352
+ total += best;
353
+ }
354
+ return total;
355
+ }
356
+
357
+ const matches = computed<TzRow[]>(() => {
358
+ const all = index.value ?? [];
359
+ const q = fold(query.value);
360
+ if (!q) {
361
+ /* ⚠ The zone in force is PINNED to the top of the unfiltered list. Only
362
+ the first `LIMIT` rows are rendered, so a zone at index 338 of 420 could
363
+ not be highlighted on open, and `aria-activedescendant` pointed at an id
364
+ that was not in the document. Measured with Europe/London stored. */
365
+ const cur = all.find((r) => r.value === props.modelValue);
366
+ if (!cur || cur === all[0]) return all;
367
+ return [all[0], cur, ...all.slice(1).filter((r) => r !== cur)];
368
+ }
369
+ const terms = q.split(' ').filter(Boolean);
370
+ const scored: Array<{ row: TzRow; score: number; i: number }> = [];
371
+ all.forEach((row, i) => {
372
+ const s = score(row, terms);
373
+ if (s >= 0) scored.push({ row, score: s, i });
374
+ });
375
+ // Ties keep the platform's own order, which is alphabetical by id — so a
376
+ // list of equally-good answers does not reshuffle itself between keystrokes.
377
+ scored.sort((a, b) => b.score - a.score || a.i - b.i);
378
+ return scored.map((x) => x.row);
379
+ });
380
+
381
+ const visible = computed(() => matches.value.slice(0, LIMIT));
382
+ const hidden = computed(() => Math.max(0, matches.value.length - visible.value.length));
383
+
384
+ /** The closed field's text. Never a query: a control that keeps your typing
385
+ * on screen after you walk away looks like it saved it.
386
+ *
387
+ * ⚠ It reads correctly BEFORE the index exists — the field is drawn the
388
+ * moment its pane is, and the index is only built when it is focused. */
389
+ const display = computed(() => {
390
+ if (!props.modelValue) return defaultLabel.value;
391
+ const zone = props.modelValue;
392
+ const city = (zone.split('/').pop() ?? zone).replace(/_/g, ' ');
393
+ const off = namePart(zone, tag.value, 'shortOffset');
394
+ return off ? `${city} — ${off}` : city;
395
+ });
396
+
397
+ function rowTime(row: TzRow): string {
398
+ try {
399
+ return row.fmt ? row.fmt.format(tick.value) : '';
400
+ } catch {
401
+ return '';
402
+ }
403
+ }
404
+
405
+ function openList(): void {
406
+ ensureIndex();
407
+ open.value = true;
408
+ query.value = '';
409
+ startTick();
410
+ const idx = visible.value.findIndex((r) => r.value === props.modelValue);
411
+ active.value = idx >= 0 ? idx : 0;
412
+ void nextTick(() => {
413
+ measureDrop();
414
+ scrollActiveIntoView();
415
+ });
416
+ }
417
+
418
+ /** Close WITHOUT choosing: the field goes back to saying what is in force. */
419
+ function closeList(): void {
420
+ open.value = false;
421
+ query.value = '';
422
+ active.value = 0;
423
+ stopTick();
424
+ }
425
+
426
+ function onInput(ev: Event): void {
427
+ ensureIndex();
428
+ if (!open.value) startTick();
429
+ open.value = true;
430
+ query.value = (ev.target as HTMLInputElement).value;
431
+ active.value = 0;
432
+ }
433
+
434
+ function commit(row: TzRow): void {
435
+ closeList();
436
+ inputEl.value?.blur();
437
+ if (row.value === props.modelValue) return;
438
+ emit('update:modelValue', row.value);
439
+ }
440
+
441
+ function scrollActiveIntoView(): void {
442
+ const el = listEl.value?.querySelector<HTMLElement>('[data-tz-active="1"]');
443
+ el?.scrollIntoView({ block: 'nearest' });
444
+ }
445
+
446
+ function moveActive(delta: number): void {
447
+ const n = visible.value.length;
448
+ if (n === 0) return;
449
+ // Clamp first: a filter that shrank the list can leave the cursor past its
450
+ // end, and `aria-activedescendant` would then name a row nobody can see.
451
+ const from = Math.min(active.value, n - 1);
452
+ active.value = (from + delta + n) % n;
453
+ void nextTick(() => scrollActiveIntoView());
454
+ }
455
+
456
+ function onKeydown(ev: KeyboardEvent): void {
457
+ switch (ev.key) {
458
+ case 'ArrowDown':
459
+ ev.preventDefault();
460
+ if (!open.value) openList();
461
+ else moveActive(1);
462
+ break;
463
+ case 'ArrowUp':
464
+ ev.preventDefault();
465
+ if (!open.value) openList();
466
+ else moveActive(-1);
467
+ break;
468
+ case 'Home':
469
+ if (!open.value) return;
470
+ ev.preventDefault();
471
+ active.value = 0;
472
+ void nextTick(() => scrollActiveIntoView());
473
+ break;
474
+ case 'End':
475
+ if (!open.value) return;
476
+ ev.preventDefault();
477
+ active.value = Math.max(0, visible.value.length - 1);
478
+ void nextTick(() => scrollActiveIntoView());
479
+ break;
480
+ case 'Enter': {
481
+ if (!open.value) return;
482
+ ev.preventDefault();
483
+ const row = visible.value[active.value];
484
+ if (row) commit(row);
485
+ break;
486
+ }
487
+ case 'Escape':
488
+ if (!open.value) return;
489
+ // ⚠ Stopped here. Both hosts are dialogs that close on Escape, and the
490
+ // first Escape belongs to the list that is open on top of them.
491
+ ev.preventDefault();
492
+ ev.stopPropagation();
493
+ closeList();
494
+ break;
495
+ case 'Tab':
496
+ closeList();
497
+ break;
498
+ }
499
+ }
500
+ </script>
501
+
502
+ <template>
503
+ <!-- A real combobox (`role="combobox"` + `aria-expanded` + `aria-controls`
504
+ + `aria-activedescendant`), not a div that listens for clicks: the list
505
+ is reachable by arrow keys and announced to a screen reader as what it
506
+ is. -->
507
+ <div class="fe-tzpick">
508
+ <input
509
+ :id="inputDomId"
510
+ ref="inputEl"
511
+ class="fe-tzpick__input"
512
+ type="text"
513
+ role="combobox"
514
+ autocomplete="off"
515
+ spellcheck="false"
516
+ aria-autocomplete="list"
517
+ :aria-expanded="open"
518
+ :aria-controls="listId"
519
+ :aria-activedescendant="open && visible.length ? `${listId}-opt-${active}` : undefined"
520
+ :disabled="disabled"
521
+ :data-testid="`${tid}-filter`"
522
+ :placeholder="t('tz.search')"
523
+ :value="open ? query : display"
524
+ @focus="openList"
525
+ @input="onInput"
526
+ @keydown="onKeydown"
527
+ @blur="closeList"
528
+ />
529
+ <ul
530
+ v-if="open"
531
+ :id="listId"
532
+ ref="listEl"
533
+ class="fe-tzpick__list"
534
+ :class="{ 'is-above': drop.above }"
535
+ :style="{ maxHeight: drop.maxHeight }"
536
+ role="listbox"
537
+ :aria-label="label || t('tz.title')"
538
+ :data-testid="`${tid}-list`"
539
+ >
540
+ <li
541
+ v-for="(row, i) in visible"
542
+ :id="`${listId}-opt-${i}`"
543
+ :key="row.value || '__default'"
544
+ class="fe-tzpick__opt"
545
+ :class="{ 'is-active': i === active, 'is-current': row.value === modelValue }"
546
+ :data-tz-active="i === active ? '1' : '0'"
547
+ :data-testid="`${tid}-opt-${row.value || 'device'}`"
548
+ role="option"
549
+ :aria-selected="row.value === modelValue"
550
+ :title="row.value || fallbackZone"
551
+ @mousedown.prevent="commit(row)"
552
+ @mousemove="active = i"
553
+ >
554
+ <span class="fe-tzpick__main">
555
+ <span class="fe-tzpick__city">{{ row.label }}</span>
556
+ <span v-if="row.region" class="fe-tzpick__region">{{ row.region }}</span>
557
+ </span>
558
+ <span class="fe-tzpick__meta">
559
+ <span v-if="rowTime(row)" class="fe-tzpick__time">{{ rowTime(row) }}</span>
560
+ <span class="fe-tzpick__offset">{{ row.offset }}</span>
561
+ </span>
562
+ </li>
563
+ <li v-if="visible.length === 0" class="fe-tzpick__empty" role="presentation">
564
+ {{ t('tz.no_match', { query }) }}
565
+ </li>
566
+ <li v-else-if="hidden > 0" class="fe-tzpick__empty" role="presentation">
567
+ {{ t('tz.more', { n: hidden }) }}
568
+ </li>
569
+ </ul>
570
+ </div>
571
+ </template>
572
+
573
+ <!-- Deliberately NOT scoped: the webcomponent injects the CORE package's built
574
+ style.css, whose data-v scope ids do not match the ids the webcomponent's
575
+ own compile assigns, so scoped rules would silently not apply in embeds.
576
+ Class names are fe-* namespaced (same approach as ThemePalette). -->
577
+ <style>
578
+ /* The list is absolutely positioned so it OVERLAYS what is under it instead of
579
+ pushing the rest of the pane down: opening a zone list must not move the
580
+ next control out from under the pointer. */
581
+ .fe-tzpick {
582
+ position: relative;
583
+ z-index: 2;
584
+ }
585
+ .fe-tzpick__input {
586
+ box-sizing: border-box;
587
+ width: 100%;
588
+ height: var(--fe-h-md);
589
+ padding: 0 10px;
590
+ border: 1px solid var(--fe-border);
591
+ border-radius: var(--fe-radius);
592
+ background: var(--fe-bg);
593
+ color: var(--fe-text);
594
+ font: inherit;
595
+ font-size: var(--fe-text-sm);
596
+ }
597
+ .fe-tzpick__input:focus {
598
+ outline: none;
599
+ border-color: var(--fe-primary);
600
+ }
601
+ .fe-tzpick__input:disabled {
602
+ background: var(--fe-bg-elev);
603
+ color: var(--fe-text-muted);
604
+ }
605
+ .fe-tzpick__list {
606
+ position: absolute;
607
+ top: calc(100% + 4px);
608
+ left: 0;
609
+ right: 0;
610
+ z-index: 5;
611
+ box-sizing: border-box;
612
+ margin: 0;
613
+ padding: var(--fe-gap-xs);
614
+ list-style: none;
615
+ overflow-y: auto;
616
+ border: 1px solid var(--fe-border);
617
+ border-radius: var(--fe-radius);
618
+ background: var(--fe-bg);
619
+ box-shadow: var(--fe-shadow-sm);
620
+ }
621
+ .fe-tzpick__list.is-above {
622
+ top: auto;
623
+ bottom: calc(100% + 4px);
624
+ }
625
+ .fe-tzpick__opt {
626
+ display: flex;
627
+ align-items: center;
628
+ gap: var(--fe-gap-sm);
629
+ min-height: var(--fe-h-md);
630
+ padding: 0 var(--fe-gap-sm);
631
+ border-radius: var(--fe-radius-sm);
632
+ color: var(--fe-text);
633
+ cursor: pointer;
634
+ }
635
+ /* Two separate states, and they are different things: `is-active` is where
636
+ the keyboard is, `is-current` is what is actually in force. A list that
637
+ painted only one of them cannot answer "which one am I on?" and "which one
638
+ did I choose?" at the same time. */
639
+ .fe-tzpick__opt.is-active {
640
+ background: var(--fe-bg-hover);
641
+ }
642
+ .fe-tzpick__opt.is-current {
643
+ color: var(--fe-primary);
644
+ font-weight: 600;
645
+ }
646
+ .fe-tzpick__opt.is-current.is-active {
647
+ background: var(--fe-primary-soft);
648
+ }
649
+ .fe-tzpick__main {
650
+ display: flex;
651
+ align-items: baseline;
652
+ gap: var(--fe-gap-sm);
653
+ flex: 1 1 auto;
654
+ min-width: 0;
655
+ }
656
+ .fe-tzpick__city {
657
+ overflow: hidden;
658
+ text-overflow: ellipsis;
659
+ white-space: nowrap;
660
+ font-size: var(--fe-text-sm);
661
+ }
662
+ .fe-tzpick__region {
663
+ flex: 0 0 auto;
664
+ font-size: var(--fe-text-xs);
665
+ font-weight: 400;
666
+ color: var(--fe-text-muted);
667
+ }
668
+ /* The offset, and the clock beside it, are the reason a row is readable
669
+ without knowing IANA ids. Tabular figures so the column does not jitter as
670
+ the minutes tick. */
671
+ .fe-tzpick__meta {
672
+ flex: 0 0 auto;
673
+ display: flex;
674
+ align-items: baseline;
675
+ gap: var(--fe-gap-sm);
676
+ font-size: var(--fe-text-xs);
677
+ color: var(--fe-text-muted);
678
+ font-variant-numeric: tabular-nums;
679
+ }
680
+ .fe-tzpick__time {
681
+ color: var(--fe-text);
682
+ }
683
+ .fe-tzpick__empty {
684
+ padding: var(--fe-gap-sm);
685
+ font-size: var(--fe-text-xs);
686
+ line-height: 1.45;
687
+ color: var(--fe-text-muted);
688
+ }
689
+ /* On a phone the offset stays and the clock goes: 390px minus a pane's
690
+ padding leaves a city column too narrow to truncate politely otherwise. */
691
+ @media (max-width: 480px) {
692
+ .fe-tzpick__time {
693
+ display: none;
694
+ }
695
+ }
696
+ </style>