@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.
- package/README.md +32 -25
- package/dist/ArchiveViewer-BNqukFg8.js +124 -0
- package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
- package/dist/CsvViewer-ii_-MgmG.js +141 -0
- package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
- package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
- package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
- package/dist/EpubViewer-p4B8iiWb.js +163 -0
- package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
- package/dist/IpynbViewer-D_qYRJhu.js +184 -0
- package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
- package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
- package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
- package/dist/PsdViewer-CuV-OPSd.js +122 -0
- package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
- package/dist/TiffViewer-BLeoF4Be.js +142 -0
- package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
- package/dist/Viewer3D-DHhhU76E.js +75 -0
- package/dist/Viewer3D-DHhhU76E.js.map +1 -0
- package/dist/filex-core.js +217 -20077
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +61 -62
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-BraG7Cz4.js +26664 -0
- package/dist/index-BraG7Cz4.js.map +1 -0
- package/dist/index.d.ts +4276 -851
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +2170 -492
- package/src/components/AdvancedSearch.vue +870 -0
- package/src/components/Breadcrumb.vue +191 -19
- package/src/components/CommandPalette.vue +66 -22
- package/src/components/ConnectionsPanel.vue +6 -2
- package/src/components/ContextMenu.vue +57 -2
- package/src/components/EncryptedFolderModal.vue +31 -25
- package/src/components/FilePane.vue +939 -0
- package/src/components/FilterBar.vue +568 -72
- package/src/components/GalleryView.vue +162 -30
- package/src/components/GridView.vue +322 -58
- package/src/components/HomeView.vue +331 -0
- package/src/components/InspectorPanel.vue +296 -53
- package/src/components/ListView.vue +1158 -167
- package/src/components/NFSExportsPanel.vue +7 -4
- package/src/components/OnboardingTour.vue +156 -33
- package/src/components/QuickLook.vue +34 -5
- package/src/components/RecentlyOpened.vue +17 -2
- package/src/components/S3KeysPanel.vue +7 -4
- package/src/components/SSHKeysPanel.vue +7 -4
- package/src/components/ShortcutSettings.vue +43 -2
- package/src/components/ShortcutsHelp.vue +4 -1
- package/src/components/SideNav.vue +206 -140
- package/src/components/StorageFields.vue +19 -1
- package/src/components/TabBar.vue +87 -1
- package/src/components/TagPicker.vue +77 -6
- package/src/components/ThemeGallery.vue +13 -198
- package/src/components/ThemePalette.vue +244 -0
- package/src/components/TimeZoneDialog.vue +127 -0
- package/src/components/TimeZonePicker.vue +696 -0
- package/src/components/TokensPanel.vue +16 -4
- package/src/components/Toolbar.vue +965 -469
- package/src/components/ViewSwitcher.vue +9 -4
- package/src/composables/useConnections.ts +35 -4
- package/src/composables/useExplorerTimeZone.ts +85 -0
- package/src/composables/useFileApi.ts +53 -4
- package/src/composables/useKeyboardShortcuts.ts +122 -0
- package/src/composables/useLocale.ts +365 -25
- package/src/index.ts +182 -3
- package/src/lib/actionIcons.ts +330 -0
- package/src/lib/advSearch.ts +162 -0
- package/src/lib/dateGroups.ts +197 -0
- package/src/lib/destinationTree.ts +235 -0
- package/src/lib/downloadSelection.ts +154 -0
- package/src/lib/fileFilters.ts +319 -12
- package/src/lib/fileIcons.ts +299 -5
- package/src/lib/filePreview.ts +605 -0
- package/src/lib/listing.ts +72 -0
- package/src/lib/shareTtl.ts +11 -1
- package/src/lib/sortOrder.ts +459 -0
- package/src/lib/themes.ts +152 -23
- package/src/lib/timezone.ts +456 -0
- package/src/lib/uiProfile.ts +82 -0
- package/src/lib/viewPrefs.ts +1036 -0
- package/src/locales/en.ts +432 -25
- package/src/locales/tr.ts +425 -25
- package/src/modals/ConvertModal.vue +23 -3
- package/src/modals/DestinationPickerModal.vue +341 -0
- package/src/modals/Modal.vue +18 -4
- package/src/modals/NewDocumentModal.vue +525 -0
- package/src/modals/NewFolderModal.vue +15 -9
- package/src/modals/PermissionsModal.vue +543 -473
- package/src/modals/PreviewModal.vue +516 -156
- package/src/styles/base.css +5435 -8
- package/src/styles/variables.css +140 -60
- package/src/types/Connections.ts +9 -0
- package/src/types/ExplorerConfig.ts +144 -39
- package/src/types/FileNode.ts +28 -0
- package/src/viewers/ArchiveViewer.vue +46 -8
- package/src/viewers/CsvViewer.vue +19 -2
- package/src/viewers/DrawioViewer.vue +12 -1
- package/src/viewers/EpubViewer.vue +50 -6
- package/src/viewers/IpynbViewer.vue +19 -2
- package/src/viewers/MermaidViewer.vue +20 -3
- package/src/viewers/PdfViewer.vue +24 -4
- package/src/viewers/PsdViewer.vue +28 -4
- package/src/viewers/TiffViewer.vue +19 -2
- package/src/viewers/Viewer3D.vue +36 -2
- package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
- package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
- package/dist/CsvViewer-CqWeV8VO.js +0 -131
- package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
- package/dist/DrawioViewer-BNALOB04.js.map +0 -1
- package/dist/EpubViewer-BPmlImG7.js +0 -145
- package/dist/EpubViewer-BPmlImG7.js.map +0 -1
- package/dist/IpynbViewer-CDlQmuCV.js +0 -175
- package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
- package/dist/MermaidViewer-sfZWdR-d.js +0 -127
- package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
- package/dist/PsdViewer-DwuySZsb.js +0 -112
- package/dist/PsdViewer-DwuySZsb.js.map +0 -1
- package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
- package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
- package/dist/Viewer3D-CJJJgkuv.js +0 -60
- package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
- package/src/components/SecondaryPane.vue +0 -447
- 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>
|